A lexicon for game activity sharing

Yeah, the Build it into Platforms for the new frontiers does seem like it’ll take a lot of the burden off both players and developers. If we have cartridge.dev client launching existing libraries and expressing this data, while atmosphere.games does similar for the proto-native experiences it features, then we might actually have the entirety of the ecosystem covered?

Theoretically @lou.gg could also have Colibri read the Steam / Epic activity broadcasts (link those accounts, as Discord does) and also write the activity records as well.

3 Likes

For proxying activity from other ecosystems into atproto, some sort of lock record would probably be smart, too. If Colibri is going to proxy those records and @byjp.me’s tool ends up doing the same, they need either a deterministic rkey so they can maintain the same record, or they need something like g5.actor.status.externalActivityLock so they can recognize when some other application is managing them.

That’s sort of tangential to this conversation about protocol native rich presence, but important if many apps are going to be proxying data into it.

2 Likes

Possible, but very much not in scope for now. Happy to revisit this and either integrate it into the desktop apps and/or give the option to use other community-run services, possibly even using those lexicons. We can just build (on) things (that already exist)!

1 Like

Superb! Can’t believe I missed both the platform lexicon and the Where to Buy link :joy:

Here’s the updated lexicon then:

use games.gamesgamesgamesgames.platform;
use app.bsky.embed.external;

/// A live "now playing a game" status, one per account.
/// Consider this record abandoned if staleAt >> now.
/// Be mindful of players' privacy preferences. Do not create this record
/// without consent from the owner to publish their live activity, and
/// consider what 'covert play' looks like in your app.
@key("literal:self")
record status {
    /// The game being played (link to a games.gamesgamesgamesgames.game record)
    game!: AtUri,

    /// The storefront/platform being used
    platform: platform, // games.gamesgamesgamesgames.platform open union

    /// The kind of session this is (eg. "Daily Challenge", "4-player game")
    state: string constrained {
        maxGraphemes: 128,
        maxLength: 1280,
    },

    /// The task being handled right now in the game
    /// eg. "Level 5-2", "Ranked - Diamond II", "Turn 14 of 20"
    details: string constrained {
        maxGraphemes: 128,
        maxLength: 1280,
    },

    /// Details only relevant if you're playing.
    /// It's presence implies you're playing, not watching.
    playing: {
        /// An ID for this gaming session which will be the same for all players playing together
        id: string constrained { maxLength: 128 }
        
        /// Who's playing in this session (only for 'playing' activity)
        party: {
            /// Count of players currently in the party
            current!: integer constrained { minimum: 1 },
            /// Who they are, including yourself, may be less than current count
            /// eg. if some players don't have atproto accounts.
            /// Be mindful of players' privacy preferences.
            /// Ignore DIDs of players who don't also have
            /// an equivalent record (use `id` to match)
            dids: Did[] constrained { minLength: 1 },
            /// Capacity, if there's a hard limit
            max: integer constrained { minimum: 1 },
        },

        // Space for lobby details & other playing-specific metadata
    },

    /// A card representing the game/session
    embed: embed, // app.bsky.embed.external

    /// When this session began
    createdAt!: Datetime,

    /// When this record should be considered abandoned.
    /// Active sessions should update this regularly (eg. every 5 mins)
    /// to a few minutes in the future.
    staleAt!: Datetime,
}
As (more verbose) lexicon JSON
{
  "$type": "com.atproto.lexicon.schema",
  "lexicon": 1,
  "id": "games.gamesgamesgamesgames.actor.status",
  "defs": {
    "main": {
      "type": "record",
      "description": "A live \"now playing a game\" status, one per account.\nConsider this record abandoned if staleAt >> now.\nBe mindful of players' privacy preferences. Do not create this record\nwithout consent from the owner to publish their live activity, and\nconsider what 'covert play' looks like in your app.",
      "key": "literal:self",
      "record": {
        "type": "object",
        "required": [
          "game",
          "createdAt",
          "staleAt"
        ],
        "properties": {
          "game": {
            "type": "string",
            "format": "at-uri",
            "description": "The game being played (link to a games.gamesgamesgamesgames.game record)"
          },
          "platform": {
            "type": "union",
            "refs": [
              "games.gamesgamesgamesgames.platform"
            ],
            "description": "The storefront/platform being used"
          },
          "state": {
            "type": "string",
            "maxGraphemes": 128,
            "maxLength": 1280,
            "description": "The kind of session this is (eg. \"Daily Challenge\", \"4-player game\")"
          },
          "details": {
            "type": "string",
            "maxGraphemes": 128,
            "maxLength": 1280,
            "description": "The task being handled right now in the game\neg. \"Level 5-2\", \"Ranked - Diamond II\", \"Turn 14 of 20\""
          },
          "playing": {
            "type": "object",
            "required": [],
            "properties": {
              "id": {
                "type": "string",
                "maxLength": 128,
                "description": "An ID for this gaming session which will be the same for all players playing together"
              },
              "party": {
                "type": "object",
                "required": [
                  "current"
                ],
                "properties": {
                  "current": {
                    "type": "integer",
                    "minimum": 1,
                    "description": "Count of players currently in the party"
                  },
                  "dids": {
                    "type": "array",
                    "items": {
                      "type": "string",
                      "format": "did"
                    },
                    "minLength": 1,
                    "description": "Who they are, including yourself, may be less than current count\neg. if some players don't have atproto accounts.\nBe mindful of players' privacy preferences.\nIgnore DIDs of players who don't also have\nan equivalent record (use `id` to match)"
                  },
                  "max": {
                    "type": "integer",
                    "minimum": 1,
                    "description": "Capacity, if there's a hard limit"
                  }
                },
                "description": "Who's playing in this session (only for 'playing' activity)"
              }
            },
            "description": "Details only relevant if you're playing.\nIt's presence implies you're playing, not watching."
          },
          "embed": {
            "type": "ref",
            "ref": "app.bsky.embed.external",
            "description": "A card representing the game/session"
          },
          "createdAt": {
            "type": "string",
            "format": "datetime",
            "description": "When this session began"
          },
          "staleAt": {
            "type": "string",
            "format": "datetime",
            "description": "When this record should be considered abandoned.\nActive sessions should update this regularly (eg. every 5 mins)\nto a few minutes in the future."
          }
        }
      }
    }
  }
}
1 Like

Oh but this is a great point. I had just assumed (foolishly) that people wouldn’t be playing two games at once.

I don’t think PDSs have an optimistic locking write, or you could just key off playing.id

1 Like

Yeah, it’s not uncommon for several games apps to be running at once. For example, if I’m playing and streaming a Beat Saber, I may have…

  • The game, Beat Saber
  • A helper app like Steam VR Tools
  • A streaming helper, like LIV

All of which are launched from Steam and have their own activity. There’s some level of “should LIV and Steam VR Tools even broadcast activity” but realistically we’ll have no control over what apps broadcast into the lexicon. Like Discord rich presence also shows when you’re streaming music from Spotify, or creating pixel art in Aseprite. :man_shrugging:t2:

2 Likes

What do you think is the right way to solve this? A lock? (first write wins — if a Beat Saber record is there, then any app writing shouldn’t write unless it too is Beat Saber kinda thing)

I can see that being able to coordinate between different apps well, and the staleAt field means you’re less likely to have clashes just because something changed between read and write.

1 Like

Maybe something like the via field that other Bluesky clients have been using? They indicate which client (Witchsky, Mu.social, etc) was used to create a post.

We could go the same route. Instead of coming up with a deterministic way of generating an rkey, we could just have apps declare an arbitrary string in a via field. That way apps have a tool to recognize and alert. For example:

  • Trezy’s Pets (game) writes its own activity record
  • @byjp.me’s tool writes an activity record for Trezy’s Pets, with via set to jp-cool-tool
  • Colibri captures those records and could alert the user, “Hey, it looks like you have multiple things writing records for this app. You may want to disable activity records for this app in JP’s Cool Tool.”

All of these examples are contrived, of course. It would be strange for the tool to write status records for an app that already handles writing its own status records. Colibri as an app probably isn’t the best place to surface these warnings.

Still, the function provides to opportunity for some (or many) tool in the chain (probs JP’s Cool Tool) to surface this information to the user.

1 Like

Are you still thinking you’d want multiple records then @trezy.codes , rather than (as we’ve been chatting about so far) using the literal:self record for whichever you feel is the game you want to show off?

I’m in favour of having just the one record (ie. keeping self) as I think it’s easier to handle for folks displaying this info & it matches app.bsky.actor.status.

As to via yeah; I like that — a field which allows an app to declare itself, so others can spot & prevent a fight over which is posting to the record!

1 Like

Another issue with locks would be that every app / game can just as easily delete or ignore the lock with their own priority. Nothing will enforce them to respect the existing locks. If the feature is valuable for outreach, they’ll have an adversarial overwrites.

TID records do solve this, but then it’s up to the apps like colibri to recognize any multiple simultaneous and how to handle it. Then the cleanup issue returns, and we’re full circle. :sweat_smile:

Most activity broadcasts that exist simply use the most recent, so like a self displaying the last written might be fine? It’s kinda the easiest method, and maybe simple is best?

4 Likes

Hey, just catching up, love where we’re headed. This seems like a perfect entry-point for atproto integration beyond auth; easy to implement and provides immediate value to the developer and user if interfaces like Bluesky and atmosphere.games integrate it.

I want to echo @lou.gg’s thought that we should have an optional lobby field that’s a URL. While some games may only have a lobby code, a URL is something interfaces can easily work with. Games where lobbies can be joined/spectated via URL get great UX while other games can provide a branded page with instructions like "enter the code below into the ‘join lobby’ box on the main menu’.

I could see an argument for making it a union of URL OR a lobby code, but I want to emphasize that the utility here is directly dependent on how many interfaces integrate it. The easier that is for them, the better. A ‘Join Lobby’ button that’s shown if the URL is present is the right ask, I think.

2 Likes

I’m definitely in favour of self, and “last write wins” does remove the incentive to (for example) set extremely long staleAt timestamps as a way to steal visibility.

A single record also makes it much easier to read and use this data (there’s one record, show it or don’t) — and if the simplest logic for multiple records would be “just pick the latest tid“ then there’s no different to “last write wins self rkey" anyway.

@lou.gg you have the use-case here, and @trezy.codes you have the deep existing knowledge — can either of you (or anyone else!) see any compelling reasons why not to stay with “last write wins, self rkey”?

I think having a historical record of play-throughs or matches is more valuable than a record of times you opened/closed the game, and you’d likely want space for much more metadata if you were keeping a full historical log (I’m thinking similar to my sts2.byjp.me playthrough data).

2 Likes

Discord has a timestamps {countUp?, countDown?} object in its rich presence

I do, actually. Discord does display multiple game activities fine! They make it multiple cards. I wouldn’t be opposed to each record being keyed by something unique, not TID. Maybe a unique NSID? That’d make it a lot easier to clear the records, too. I know some collections use DIDs instead of TIDs, for example.

1 Like

Am I right that you’d use this so your app can show people playing multiple games at once?

If so, I think the only alternative rkey we could use would be the compound of a game-specific id (eg. the rkey of the game’s g5.game record) + a deterministic id for the specific session (to prevent clashes between games). This would let:

  • AppViews verify the other players in the party without coordination, by looking for the same rkey in their repos
  • The game code/sdk inside the game be stateless (no need to store the atproto actor status key, so you can update/delete the same record as the session evolves)

That’s a bunch of extra complexity that we can’t enforce the correctness of (unlike the single-software-org Discord implementation); some implementations will make mistakes, indefinitely.

The failure mode here, for an incorrect implementation, is you’d see N instances of the same game session “live” on one account (one for each update written for the same session with an accidentally different rkey), and no record clean up when the session ends (the game doesn’t know the rkeys to delete - they’d be hidden because of staleAt, but the collection would just grow without value).

It’s definitely doable (especially with well-maintained sdks for each software language used for games), but it feels like a lot of additional complexity; is being able to show multiple games being played at once worth it? Or is there some other value of this feature we haven’t explored?

1 Like

As a game developer with a love of weird languages, I value low implementation complexity over displaying multiple games. Note that it would also raise questions when integrating the lexicon: are Bluesky and others expected to always show them all? If they only want to show one, which should it be? This plus the risk of duplicates/client bugs might turn them off. The average user might ask “why is Bluesky broken” if they see 5 copies of the same activity. Unfair but understandable given their lack of protocol knowledge.

I like last write wins + a convention of asking the user which they want to display if there’s already one active.

2 Likes

My top concern with limiting these records to a single self key is that we’re locking into that forever (or at least a very long time). Relaxing the key: literal-self constraint to key: any breaks backwards compatibility, so if in the future we decide multiple statuses is desirable then we’d have to version a new lexicon and expect the ecosystem to catch up. It’s one of those generational decisions that I don’t feel awesome about it.

Beyond that, it introduces the adversarial condition @rpg.actor mentioned, where multiple applications are fighting to “own” a user’s rich presence (it’s essentially advertising space, after all). If the applications are constantly updating the record, should apps always show the most current? What if the apps are updating a users status every ~10s? That feels bad.

Personally, I’d lean towards…

  • Expect (because we can’t enforce) one status record per game based on rkey (maybe based on the AT URI for the games metadata record?).
  • Status record carries both createdAt and staleAt (for which we should define a maximum to avoid the far future value that prevents the record from being dismissed).
  • We offer recommendations on how to show status, but applications can do as they please.
    • Recommendation for multiple status display: sort by createdAt.
    • Recommendation for single status display: sort by createdAt - staleAt to display longest active session.

This feels like an issue for app developers to handle. This could be the case for any record type. App developers leveraging rich presence should be building defensively anyway (e.g. even if 30 Valheim status records are present, the app should only show the latest). We could even add to the display recommendations: dedupe on game field.

As for stale records, is it the end of the world if records don’t get cleaned up? Platforms like Cartridge and atmosphere.games, and tools like @byjp.me’s, could handle cleanup separately (if a status record’s staleAt is more than 2hrs old, delete).

I get the desire to keep things easy for game developers, but I’m not convinced the complexity of using something like a game ID is significantly worse than using self when it carries other complexity concerns like how to avoid overwriting another applications active status.

2 Likes

Indeed!

I actually love those benefits, haven’t actually thought about that.

Regarding the concerns about record pile-up and complexity, I think a lot of this could be mitigated by good documentation and ready-to-use plugins for existing engines. I agree it should be the app’s problem if multiple records exist, we can provide guidance on how they might do that!

I believe a flipping status because of self is worse, as it impacts more users. Imagine a user in your sidebar changing their game every x minutes, or one of them just flickering in for a bit. That’s making it worse for everyone!

1 Like

Is it? Isn’t this what discord does? I thought having that info on your screen in real time was part of the purpose here, I don’t quite follow why you see it as a downside.

You’re right, using a game ID doesn’t add any real complexity for the game developers. I’m more concerned with the complexity for potential adopters like Bluesky, given that the added failure mode is an “issue for app developers to handle”. It’s completely valid to make it their problem, but the more problems they have to address the less uptake we’ll see. Given that the value here rests squarely on that adoption, we should consider carefully whether decisions we make for future-proofing or expressiveness are worth the cost. I’d prefer we err on the side of designing something dead-simple to adopt.

I’m not too concerned about apps fighting to own my presence. It is valuable advertising space, but so is a Bluesky profile. If an app changes either without my consent, that’s a breach of trust and I will quickly revoke that permission and begin complaining loudly.

2 Likes

Oh, do they? I thought they changed it recently to where it shows a stack of cards that brings up the full profile so you can see everything that a user is doing. I might be wrong, though!

The thing I see as a downside is that flickering of a game status could be perceived as a visual bug.

As for the “making it easy to adopt part”, I think we could provide tooling for this. Maybe the records could still be ordered by time somehow, or we could have an appview that returns only the latest record with a simple XRPC endpoint? Would love to hear more thoughts on this.

2 Likes