Anear Developer Guide

Table of Contents

  1. Overview
  2. Architecture
  3. Anear Event Stages
  4. Participant Presence Event Flow
  5. The anearEvent Object
  6. XState Integration
  7. Event Handling
  8. UI Development
  9. Asset Management
  10. Deployment
  11. Example: Tic-Tac-Toe Game

Overview

The Anear platform enables developers to create location-based games and activities that can involve hundreds of participants, each with their own state, experience, and real-time view updates. This guide shows how to leverage JavaScript, XState, and web technologies to build engaging multiplayer experiences.

Key Concepts

  • State Machine Architecture: All game logic is managed through XState state machines
  • Event-Driven Design: AEM (Anear Event Manager) events trigger state transitions
  • Real-time Updates: Ably pub/sub handles all real-time communication
  • CDN Asset Management: CSS and images are automatically uploaded to CloudFront
  • Template System: Pug templates provide dynamic UI rendering

How to structure StateMachine.js (actors + lifecycle)

An Anear AppMachine (AppM) is not “a UI flow chart” — it’s a model of the interaction between real people in a room. Your StateMachine.js should be organized around the actors (host/participants/spectators), the shared timeline of the experience (lobby → game → ending), and the kinds of actions those actors can take.

In practice, most apps use a small set of top-level phases:

  • Host setup / created: the event exists, but you intentionally have not made it joinable yet. This phase is where the host configures the experience (question set, teams, rules, etc.).
  • Lobby / announced: you call anearEvent.announceEvent() once the host is ready. From this point, QR scanners can join (often as spectators first), and you can decide if/when they are allowed to “join” as participants.
  • Live / in-progress: you call anearEvent.startEvent() when the game actually begins. Your AppM might stay in the same XState state name (“gameInProgress”) for a long time, while many events and re-renders occur inside it.
  • Ending / complete: you show a final view, optionally wait for a RENDERED event so everyone actually sees it, then explicitly end the event with closeEvent() or cancelEvent().

Two key ideas that keep StateMachine.js understandable:

1) Render rules live in meta, not inside action handlers. Anear’s renderer reads the state’s meta and figures out what to show to each actor. You typically set one or more of:

  • meta.host: view for the host
  • meta.allParticipants: one shared view for everyone who is an active participant
  • meta.eachParticipant: either a single view name or a function that returns per-participant display instructions (great for private state)
  • meta.spectators: view for spectators

2) Use presence events to build your own roster. Your AppM learns about people through presence messages routed as:

  • HOST_ENTER / HOST_RECONNECT / HOST_DISCONNECT / HOST_EXIT (role-specific, sent to the AppM)
  • PARTICIPANT_ENTER / PARTICIPANT_RECONNECT / PARTICIPANT_DISCONNECT / PARTICIPANT_EXIT
  • SPECTATOR_ENTER

The first participant to enter is often the event creator (and therefore the “host” in a social sense), but your app can choose whether to treat that person as:

  • A real host actor (distinct host UI and host-only actions), or
  • A participant who also has host powers (common for turn-based board games).

Finally, you’ll almost always want to gate the join experience with Created vs Announced:

  • In Created, the host can safely set up without random QR joins.
  • In Announced, QR joins work because participant registration is allowed, and the AppM will start receiving SPECTATOR_ENTER / PARTICIPANT_ENTER.

Common in-person game interaction archetypes

Below are common “shapes” of Anear games. These are intentionally framed as interaction models (who can do what, when), because that naturally leads to a clean StateMachine.js.

1) Multiplayer on a shared viewable board (turn-based)

This is the “board game” pattern: everyone sees the same shared game surface (board, grid, tokens), and one participant is allowed to act at a time. The event creator is typically also a participant (often “Player X”), and spectators are usually allowed to watch at any time.

Your AppM context typically contains:

  • Shared public state: the board, turn index, score, winner, etc.
  • A roster / role assignment: which participant IDs correspond to which tokens/seats.

Rendering is usually simple: participants and spectators can often share the same view (“ViewableGameBoard”), while the active player sees a clickable version (“PlayableGameBoard”). For turn timers, use <anear-timeout> in the interactive participant view and handle a normal action event (commonly ACTION_TIMEOUT) in your AppM.

Representative pseudo-XState (based on the Tic-Tac-Toe style app):

const Config = {
  id: 'shared-board-game',
  initial: 'registration',
  states: {
    registration: {
      initial: 'waitForFirstPlayer',
      states: {
        waitForFirstPlayer: {
          on: {
            PARTICIPANT_ENTER: {
              actions: ['assignPlayerX', 'announceEvent'],
              target: 'waitForOpponent'
            }
          }
        },
        waitForOpponent: {
          meta: { eachParticipant: 'ViewableGameBoard', spectators: 'ViewableGameBoard' },
          on: {
            SPECTATOR_ENTER: { target: 'waitForOpponent' }, // handled => triggers re-render
            PARTICIPANT_ENTER: { actions: 'assignPlayerO', target: '#gameInProgress' }
          }
        }
      }
    },
    gameInProgress: {
      id: 'gameInProgress',
      entry: ['startEvent'],
      meta: {
        eachParticipant: { view: 'PlayableGameBoard' },
        spectators: 'ViewableGameBoard'
      },
      on: {
        MOVE: { guard: 'isValidMoveAndIsPlayersTurn', actions: ['applyMove'], target: 'gameInProgress' },
        ACTION_TIMEOUT: { actions: ['setTimeoutOutcome'], target: '#gameIsEnding' },
      }
    },
    gameIsEnding: {
      id: 'gameIsEnding',
      meta: { eachParticipant: 'CloseGame', spectators: 'CloseGame' },
      on: { RENDERED: '#complete' }
    },
    complete: { id: 'complete', entry: ['closeEvent'], type: 'final' }
  }
}

This archetype is where simple invariants pay off: a MOVE guard should check both “is the square empty?” and “did the current-turn participantId send this action?”.

2) Hosted / emcee-led (trivia, polls, guided activities)

This is the “the room follows a conductor” pattern. One person (the host/emcee) controls the pacing: they set up content, start the experience, present prompts, and advance to the next stage. Participants respond in bursts (answering a poll question, submitting a guess), and spectators can watch without responding.

The most important structural decision is whether you treat “host” as:

  • A distinct actor with distinct UI (meta.host), or
  • “Just another participant” with extra powers (host-only guards).

You typically keep the event in Created while the host sets up, then call announceEvent() so QR scanners can enter, and finally call startEvent() when the host begins the activity. When collecting responses, the allParticipants timeout pattern is ideal: the AEM tracks who has responded and either marks the final response with finalAction: true or sends ACTIONS_TIMEOUT with nonResponderIds.

Representative pseudo-XState (based on the SparkPoll style app):

const Config = {
  id: 'hosted-game',
  initial: 'hostSetup', // stays Created until host is ready
  states: {
    hostSetup: {
      meta: { host: 'host/SetupScreen', spectators: 'shared/WaitingForHostScreen' },
      on: { HOST_READY: 'announced' }
    },
    announced: {
      entry: 'announceEvent',
      meta: {
        host: 'shared/AnnouncedScreen',          // host sees “Start” button
        allParticipants: 'shared/AnnouncedScreen', // participants see “Starting soon”
        spectators: 'shared/AnnouncedScreen'
      },
      on: {
        PARTICIPANT_ENTER: { actions: 'addRespondent' },
        START: { actions: 'startEvent', target: 'question' }
      }
    },
    question: {
      meta: {
        host: 'host/QuestionInProgressScreen',
        // AEM enforces “everyone respond by X ms”.
        allParticipants: { view: 'participant/QuestionScreen', timeout: 20000 },
        spectators: 'participant/QuestionScreen',
        // Optional: per-participant acknowledgements (Thanks/TimedOut).
        eachParticipant: (context, event) => {
          if (event.type === 'ANSWER') return [{ participantId: event.participantId, view: 'participant/Thanks' }]
          if (event.type === 'ACTIONS_TIMEOUT') return event.nonResponderIds.map(id => ({ participantId: id, view: 'participant/TimedOut' }))
          return []
        }
      },
      on: {
        ANSWER: [{ guard: ({ event }) => event.finalAction === true, actions: 'computeResults' }],
        ACTIONS_TIMEOUT: { actions: 'computeResults' },
        PROCEED: 'question' // next question
      }
    }
  }
}

The “hosted” archetype tends to have very clean top-level phases (setup → announced → live loop → results → end), because the host is the explicit state-advancer.

3) Multiplayer with private player state (cards, dice, hidden roles)

This is the “everyone has secrets” pattern: each participant has a private hand/cup/role that should not be visible to others until reveal moments. You may still have a shared public area (current bid, dealer cards, communal pool), but each participant’s display is different.

This is where meta.eachParticipant as a function becomes the core organizing tool. Your AppM context typically has:

  • Public state: turn order, current bid, public counters, phase.
  • Private per-player state: stored under context.players[participantId], e.g. dice cup, hand, role.

When rendering, you send a per-participant view with props derived from that participant’s role (bidder vs judge) and their private state. Timeouts are often selective: only the current bidder gets a bid timeout, or only judges get a “challenge window” timeout. You may also use allParticipants timeouts for synchronized “everyone do this once” moments (roll dice, acknowledge round result).

Representative pseudo-XState (based on the NeonBluff style app):

const Config = {
  id: 'private-state-game',
  initial: 'lobby',
  states: {
    lobby: {
      meta: { eachParticipant: 'Lobby' }, // eachParticipant => templates get `participant`
      on: {
        PARTICIPANT_ENTER: { actions: ['addPlayer', 'announceEvent'] },
        START_GAME: { guard: 'hostOnly', target: 'round' }
      }
    },
    round: {
      entry: 'startEvent',
      initial: 'rolling',
      states: {
        rolling: {
          meta: { allParticipants: { view: 'Rolling', timeout: 15000 } },
          on: {
            ROLL: [{ guard: ({ event }) => event.finalAction === true, target: 'bidding' }],
            ACTIONS_TIMEOUT: { actions: 'autoRollForNonResponders', target: 'bidding' }
          }
        },
        bidding: {
          meta: {
            // Everyone gets the same template name, but each participant receives a different
            // computed view payload (private dice/hand + role-based controls + selective timeouts).
            eachParticipant: (ctx, event) => {
              return (ctx.turnOrder || []).map(pid => {
                const role = ctx.turnOrder[ctx.currentTurnIndex] === pid ? 'bidder' : 'judge'
                const timeout = (role === 'bidder') ? 10000 : null
                return { participantId: pid, view: 'Bidding', timeout }
              })
            },
            // Optional: declarative cancel so a challenge action immediately cancels outstanding timers.
            cancel: 'CHALLENGE',
          },
          on: {
            CONFIRM_BID: { guard: 'bidderOnly', target: 'judgingWindow' },
          }
        },
      }
    }
  }
}

The core rule: never derive “public” UI from private state. If a value should be secret, only compute it inside the per-participant branch for that participant.

4) Open house / come-and-go experiences (people arrive, leave, return)

This is the “ambient room” pattern: people may arrive late, wander out, and come back. These experiences often feel better when the event stays joinable for most (or all) of its lifetime, and the AppM treats reconnects and re-joins as normal rather than fatal.

When the event is configured with openHouse: true, Anear modifies lifecycle handling so that participants who hard-exit can rejoin later without “starting from scratch” at the Anear system level. Practically, your AppM should:

  • Treat PARTICIPANT_DISCONNECT as a temporary absence.
  • Treat PARTICIPANT_EXIT as “they left the room”, but don’t necessarily delete their slot from your own context (you may want to preserve a roster/history).
  • Handle PARTICIPANT_RECONNECT (and sometimes PARTICIPANT_UPDATE) by refreshing their view and marking them active again.

It’s also common to stay in Announced longer (or even the whole time) so QR joins keep working. In these apps, you’ll often track presence in your own context so templates can show “present / away / exited” badges.

Representative pseudo-XState (based on a chat/open-house style app):

const Config = {
  id: 'open-house-experience',
  initial: 'active',
  states: {
    active: {
      meta: { spectators: 'Lobby', eachParticipant: { view: 'Room' } },
      on: {
        PARTICIPANT_ENTER: [
          {
            guard: 'isFirstParticipant',
            // Keep the event joinable: announce once the creator arrives.
            actions: ['setHostId', 'addParticipant', 'markPresent', 'announceEvent']
          },
          { actions: ['addParticipant', 'markPresent'] }
        ],
        PARTICIPANT_DISCONNECT: { actions: 'markAbsent' },
        PARTICIPANT_EXIT: { actions: 'markExited' },
        PARTICIPANT_RECONNECT: { actions: ['ensureParticipant', 'markPresent'] },
        PARTICIPANT_UPDATE: { actions: ['ensureParticipant', 'markPresent'] },
      }
    },
    ending: {
      meta: { allParticipants: 'Room', spectators: 'Room' },
      on: { RENDERED: 'complete' }
    },
    complete: { entry: 'closeEvent', type: 'final' }
  }
}

Open house apps are usually less about “who wins” and more about “who is here right now”, so it’s normal for your state machine to spend a long time in a single “active” state while responding to presence and actions.

Architecture

Core Components

┌─────────────────┐    ┌──────────────────┐    ┌─────────────────┐
│   Your App      │    │   anear-js-api   │    │   Anear Core    │
│                 │    │                  │    │                 │
│ • StateMachine  │◄──►│ • AnearService   │◄──►│ • Event Manager │
│ • Constants     │    │ • CDN Upload     │    │ • Pub/Sub       │
│ • Pug Templates │    │ • Template Load  │    │ • Participant   │
│ • CSS/Assets    │    │ • Event Routing  │    │   Management    │
└─────────────────┘    └──────────────────┘    └─────────────────┘

Entry Point

// index.js
const { AnearService } = require('anear-js-api')
const MachineFactory = require('./StateMachine')

// Starts and instantiates the service
AnearService(MachineFactory)

Developer API Authentication (JWT)

anear-js-api talks to the ANAPI Developer API (the /developer/v1/* endpoints) to fetch app data, request Ably TokenRequests, and manage developer-only event operations (e.g. transitions, asset upload URLs).

Developer auth is now JWT-only:

  • Challenge (credentials → JWT): On startup / first API call, anear-js-api exchanges your developer credentials (api_key, secret) for a short-lived JWT by calling:
    • POST /developer/v1/sessions
    • body: JSON:API data.attributes.api_key + data.attributes.secret
    • response: auth_token + expires_at
  • Authenticated requests (JWT → access): All subsequent requests use:
    • Authorization: Bearer <auth_token>
    • (No per-request X-Api-Key header)

Required environment variables

These are the environment variables your App/JSAPI process must provide:

  • ANEARAPP_API_KEY: developer api_key
  • ANEARAPP_API_SECRET: developer secret
  • ANEARAPP_API_VERSION: usually v1
  • ANEARAPP_API_URL (optional): base URL for the developer API (defaults to https://api.anear.me/developer)

If you’re running against local ANAPI, ANEARAPP_API_URL typically looks like:

  • http://api.lvh.me:3001/developer

Anear Event Lifecycle

Anear events follow a specific lifecycle managed by the Anear Event Machine (AEM):

1. Created (AEM State)

  • Event is created in the AEM but not yet visible
  • No participants can join yet
  • AppM can be in any state you choose

2. Announced (AEM State)

  • Developer calls anearEvent.announceEvent()
  • Event becomes visible to potential participants
  • Host can share QR codes or links
  • Participants can join as spectators
  • Critical: This step is required for participants to join!

3. Live (AEM State)

  • Developer calls anearEvent.startEvent()
  • Event is actively running
  • Participants can interact with the game
  • Real-time updates are broadcast

4. Complete/Cancelled (AEM State)

  • Developer calls anearEvent.closeEvent() or anearEvent.cancelEvent()
  • Event has ended (naturally or by cancellation)
  • Final state is recorded
  • Participants are notified
  • Critical: You MUST explicitly call one of these methods. If your AppM reaches a final state without calling them, the event will be forcibly cancelled and an error will be logged.

Important: Your AppM state names are completely independent of these AEM states. You can start your AppM in any state you want - the AEM lifecycle runs in parallel.

State Machine Integration

const Config = {
  id: "your-game",
  initial: 'live', // Your AppM can start in any state you choose
  states: {
    live: {
      initial: 'registration',
      states: {
        registration: {
          // Handle participant joining
        },
        gameInProgress: {
          // Active gameplay
        },
        complete: {
          // Game over
        }
      }
    }
  }
}

Participant Presence Event Flow

Understanding how Ably presence events flow through the Anear system is crucial for handling participant lifecycle correctly in your AppM.

Event Mapping Table

The following table shows how Ably presence events map to AEM events, and then to AppM events, based on participant state:

Ably Presence Event Participant State AEM Event AEM Guard/Action AppM Event (to AppM) Notes
enter New participant (not in context.participants) PARTICIPANT_ENTER participantExists = false HOST_ENTER or PARTICIPANT_ENTER Role-specific event based on participant.isHost
enter Existing participant (in context.participants) PARTICIPANT_ENTER participantExists = true HOST_RECONNECT or PARTICIPANT_RECONNECT Participant rejoining - triggers reconnect flow
leave Any participant PARTICIPANT_LEAVE isPermanentLeave = true (type = PARTICIPANT_EXIT) HOST_EXIT or PARTICIPANT_EXIT Permanent exit - participant removed from context
leave Any participant PARTICIPANT_LEAVE isPermanentLeave = false (type = EVENT_UNMOUNT or other) HOST_DISCONNECT or PARTICIPANT_DISCONNECT Temporary disconnect - participant may reconnect
update Existing participant, event.data.type = undefined PARTICIPANT_UPDATE isReconnectUpdate = true HOST_RECONNECT or PARTICIPANT_RECONNECT Implicit reconnect (browser detected leave/return)
update Existing participant, event.data.type = defined PARTICIPANT_UPDATE isReconnectUpdate = false HOST_UPDATE or PARTICIPANT_UPDATE Explicit update (geo-location, heartbeat, etc.)

EVENT_UNMOUNT vs “real disconnect”

EVENT_UNMOUNT is most useful when the browser/app remains connected to Ably, but a specific event (or embedded child app) is removed from the DOM (SPA route change, parent stops rendering a child <anear-app>, etc.). In those cases Ably will not automatically leave presence (the socket is still alive), so the client should explicitly send a presence leave with a non-terminal type (e.g. EVENT_UNMOUNT) to prevent stale “present” participants.

By contrast, closing a tab / full page unload / network drop usually results in Ably detecting a disconnect and removing presence automatically. That typically shows up as a leave/update without an explicit event.data.type from the client.

Key Concepts

Role-Specific Events vs Generic Events:

  • AppM receives role-specific events: The AEM uses getPresenceEventName() to determine if the participant is a host, sending either HOST_* or PARTICIPANT_* events to your AppM.

Participant State Detection:

  • New vs Reconnect: The AEM uses the participantExists guard to check if a participant ID exists in context.participants. If true, PARTICIPANT_ENTER triggers a reconnect flow rather than a new join.
  • Reconnect vs Update: When PARTICIPANT_UPDATE is received, if event.data.type is undefined, the AEM treats it as an implicit reconnect (the browser detected the user left and returned). If event.data.type is defined, it's treated as an explicit update (geo-location, heartbeat, etc.).

OPEN_HOUSE Event Behavior

For events with the openHouse attribute set to true, participant lifecycle behavior is modified to support non-fatal re-entry:

  • Participant Preservation: When a participant exits (hard exit), they are not permanently removed from context.participants. The AEM only removes their participantMachine instance, preserving their participant data slot with active: false (idle state).
  • Active/Idle State Management: Participants in open house events have an active state tracked in their participantInfo:
    • active: true - Participant is connected and has an active participantMachine
    • active: false - Participant has exited and their machine has been removed (idle state)
    • Active state is set to true on entry, reconnect, and geo-location updates
    • Active state is set to false on disconnect or exit (for open house events only)
  • Automatic Reconnect: Subsequent PARTICIPANT_ENTER or PARTICIPANT_UPDATE events for an idle participant (one that exists in context.participants but has no machine) will trigger machine recreation. The participant's machine is recreated, their active state is set to true, and PARTICIPANT_RECONNECT (or HOST_RECONNECT) is sent to the AppM instead of treating them as new participants.
  • Display Filtering: Only active participants (those with active: true and a valid participantMachine) receive display events. Idle participants are automatically excluded from eachParticipant and allParticipants displays.
  • AppM Handling: Your AppM should handle PARTICIPANT_RECONNECT events to refresh views for returning participants, potentially updating "last seen" timestamps or restoring participant state.

Example: Handling Reconnects in OPEN_HOUSE Events:

on: {
  PARTICIPANT_ENTER: {
    actions: ['addPlayer'],
    target: 'waitForOpponent'
  },
  PARTICIPANT_RECONNECT: {
    // Participant has re-joined after a hard exit
    actions: ['refreshParticipantView', 'updateLastSeen']
    // v5 note: internal transitions are the default (v4 had `internal: true`)
  },
  PARTICIPANT_UPDATE: {
    // Could be a reconnect or geo-location update
    actions: ['handleParticipantUpdate']
    // v5 note: internal transitions are the default (v4 had `internal: true`)
  }
}

Handling Reconnects in Your AppM

When implementing participant lifecycle handling, consider:

  1. Distinguish between new joins and reconnects: Use PARTICIPANT_ENTER for new participants, PARTICIPANT_RECONNECT for returning participants.
  2. Update participant presence state: Track whether participants are currently present, absent, or exited in your AppM context.
  3. Refresh views on reconnect: Use PARTICIPANT_RECONNECT events to update UI and restore participant state, especially important for OPEN_HOUSE events.
  4. Handle both PARTICIPANT_UPDATE scenarios: Be prepared for PARTICIPANT_UPDATE to represent either a reconnect (type = undefined) or an explicit update (type = defined).

The anearEvent Object

The anearEvent object is passed to your MachineFactory and provides access to Anear's core functionality:

Core Properties

anearEvent = {
  data: {
    id: "event-id",
    attributes: {
      name: "Event Name",
      // ... other event metadata
    }
  },
  // ... other properties
}

API Methods

// Event lifecycle management
await anearEvent.announceEvent()  // Transition to announced
await anearEvent.startEvent()     // Transition to live
await anearEvent.closeEvent()     // Transition to complete
await anearEvent.cancelEvent()    // Transition to cancelled

// allParticipants timeout management
anearEvent.cancelAllParticipantsTimeout()  // Cancel active allParticipants timeout orchestration

XState Integration

State Machine Factory Pattern

const MachineFactory = anearEvent => {
  const expandedConfig = {
    predictableActionArguments: true,
    ...Config
  }
  const machine = createMachine(
    expandedConfig,
    Functions(anearEvent)
  )
  return machine.withContext(Context)
}

Context Structure

const Context = {
  C: Object.freeze(Constants), // Available in templates
  // Game-specific state
  playerIds: {},
  gameState: 'WAITING',
  // ... other state
}

Meta Extensions for UI

meta: {
  // Simple view assignment
  eachParticipant: 'PlayableGameBoard',
  spectators: 'ViewableGameBoard',

  // Legacy timeout metadata (prefer <anear-timeout> in PUG for participant-local UX)
  eachParticipant: {
    view: 'PlayableGameBoard',
    timeout: calcParticipantTimeout,
    props: {
      isTurn: true,
      lastMove: [1, 2]
    }
  },

  // Multiple view types
  allParticipants: 'ExitGame',
  spectators: 'ExitGame'
}

Meta Extensions for Targeted Anchor Updates

Use <anear-anchor> when only part of the display needs to change (score, trick count, status text) and you want to avoid full-screen re-renders.

Key defaults for targeted updates:

  • contentType defaults to 'html'
  • type defaults to 'replace'
meta: {
  // eachParticipant can return one or many target updates for each participant
  eachParticipant: (context, event) => {
    if (!event.participantId) return []

    const pid = event.participantId
    const player = context.players?.[pid] || {}

    return [
      {
        participantId: pid,
        anchor: `seat_${pid}_score`,
        content: String(player.score ?? 0),  // context-derived text value
        contentType: 'text',                 // plain text update
        type: 'replace'
      },
      {
        participantId: pid,
        anchor: `seat_${pid}_tricks`,
        content: String(player.tricksWon ?? 0),
        contentType: 'text',
        type: 'replace'
      },
      {
        participantId: pid,
        anchor: `seat_${pid}_status`,
        view: 'participant/SeatStatusBadge', // template-rendered html update
        type: 'replace'
      }
    ]
  }
}

RENDERED Event Synchronization

The RENDERED event is helpful for synchronizing display rendering with state transitions. It ensures that meta: {} displays are fully processed before the AppM proceeds to the next state, giving participants and spectators time to see important displays before transitioning to another phase or ending the event.

When to Use RENDERED

Helpful for display-only states:

// Start of event flow - no user interaction
announced: {
  meta: {
    allParticipants: 'AnnouncedScreen',
    host: 'HostScreen'
  },
  on: {
    RENDERED: 'question' // Wait for display before starting
  }
}

// End of event flow - no user interaction
gameIsEnding: {
  meta: {
    allParticipants: 'CloseGame',
    spectators: 'CloseGame'
  },
  on: {
    RENDERED: '#complete' // Wait for final display before completing
  }
}

NOT required for interactive states:

// Has user interaction - will transition naturally
question: {
  meta: {
    allParticipants: { view: 'QuestionScreen', timeout: 20000 }
  },
  on: {
    ANSWER: 'results',        // User will answer
    ACTIONS_TIMEOUT: 'results' // Or timeout will occur
    // No RENDERED needed - state transitions naturally
  }
}

RENDERED Event Flow

  1. AppM transitions to state with meta: {}
  2. Displays are rendered to participants
  3. AEM sends RENDERED when all displays complete
  4. AppM transitions to next state

Best Practices

  • Use RENDERED for states that only display content
  • Skip RENDERED for states waiting on user interactions
  • Consider including RENDERED for start/end states in event flows
  • Consider timing - ensures displays are fully rendered before proceeding
  • User experience - gives participants and spectators time to see important displays before phase transitions or event endings

Event Handling

AEM Events

All Participants Timeout Events

When using allParticipants: { view: 'Template', timeout: 20000 }, the system automatically handles collective timeouts for ALL active participants:

Event Flow:

  1. Individual responses: Each participant triggers your chosen event (e.g., ANSWER)
  2. All responded: Timer is cancelled, last ACTION has finalAction: true flag
  3. Timeout reached: Timer expires, ACTIONS_TIMEOUT sent with nonResponderIds: ['123', '456']

Key Points:

  • All active participants are included in the timeout pool
  • Mutually exclusive outcomes: You will receive EITHER:
    • An ACTION with finalAction: true (all participants responded), OR
    • An ACTIONS_TIMEOUT event (timeout expired)
  • Use finalAction: true flag on the last ACTION event to detect when all have responded
  • Use ACTIONS_TIMEOUT event to handle timeout scenarios
  • The event type itself (ACTIONS_TIMEOUT) indicates a timeout occurred (no timedOut flag needed)
  • Important: When handling the final ACTION with finalAction: true, suppress display events in your eachParticipant function to avoid conflicts with the transition to the next state

Reference Implementation: SparkPoll Pattern

SparkPoll demonstrates the recommended two-state pattern with individual acknowledgments:

presentingQuestion: {
  meta: {
    allParticipants: {
      view: 'participant/QuestionScreen',
      timeout: C.QUESTION_TIMEOUT_MS
    }
  },
  on: {
    RENDERED: 'collectingAnswers' // Wait for the display to be confirmed
  }
},
collectingAnswers: {
  meta: {
    host: 'host/QuestionInProgressScreen',
    spectators: 'participant/QuestionScreen',
    // Use eachParticipant function to send individual acknowledgments
    eachParticipant: (context, event) => {
      // On ANSWER event, show Thanks to that participant
      if (event.type === 'ANSWER' && event.participantId) {
        return [{ participantId: event.participantId, view: 'participant/Thanks' }]
      }
      // On ACTIONS_TIMEOUT, show TimedOut to non-responders
      if (event.type === 'ACTIONS_TIMEOUT' && Array.isArray(event.nonResponderIds)) {
        return event.nonResponderIds.map(id => ({ participantId: id, view: 'participant/TimedOut' }))
      }
      return []
    }
  },
  on: {
    ANSWER: [
      {
        guard: ({ event }) => event.finalAction === true,
        actions: ['recordAnswer', 'calculateResults'],
      },
      {
        actions: 'recordAnswer', // Incremental answers
      }
    ],
    ACTIONS_TIMEOUT: {
      actions: ['calculateResults']
    },
  }
}

Event Payload:

// Individual ANSWER events
ANSWER: {
  participantId: '123',
  payload: { ... },
  finalAction: false  // Not the last responder
}

ANSWER: {
  participantId: '456',
  payload: { ... },
  finalAction: true   // Last responder - all have responded
}

// Timeout event
ACTIONS_TIMEOUT: {
  timeout: 20000,
  nonResponderIds: ['123', '456']  // Participants who didn't respond
}

// Cancelled timeout (when cancelAllParticipantsTimeout() is called)
ACTIONS_TIMEOUT: {
  timeout: 20000,
  nonResponderIds: ['123', '456'],
  canceled: true  // Timeout was cancelled, not expired
}

Usage Example:

on: {
  ANSWER: [
    {
      guard: ({ event }) => event.finalAction === true,
      actions: ['handleAllResponded']
    },
    {
      actions: ['handleIndividualResponse']
    }
  ],
  ACTIONS_TIMEOUT: {
    actions: ({ event }) => {
      if (event.canceled) {
        // Handle cancelled timeout
        console.log('Timeout cancelled. Non-responders:', event.nonResponderIds)
      } else {
        // Handle expired timeout
        console.log('Timeout expired. Non-responders:', event.nonResponderIds)
      }
    }
  }
}

Participant-local Timeout UX (<anear-timeout>)

Participant-local timeouts are now owned by the browser runtime (<anear-timeout>), not by JSAPI participant machines.

Current model:

  1. Wrap interactive controls in the participant view with <anear-timeout>.
  2. On timeout, the browser emits a normal action payload (commonly mapped to ACTION_TIMEOUT).
  3. Your AppM handles that action event like any other game event.
anear-timeout(duration='30000')
  anear-action(payload=action("MOVE")) Move
on: {
  MOVE: { actions: ['applyMove'] },
  ACTION_TIMEOUT: { actions: ['setTimeoutOutcome'], target: '#cancelGame' }
}

Important: JSAPI does not emit PARTICIPANT_TIMEOUT.

Cancelling Group (allParticipants) Timeout Orchestration

Use cancelAllParticipantsTimeout() only for active allParticipants waits managed by AEM.

actions: {
  cancelAllParticipantsTimeout: () => {
    anearEvent.cancelAllParticipantsTimeout()
  }
}

When called during an active allParticipants wait, AEM emits ACTIONS_TIMEOUT with canceled: true and includes remaining nonResponderIds.

Participant Lifecycle Events

on: {
  PARTICIPANT_ENTER: {
    actions: ['addPlayer'],
    target: 'waitForOpponent'
  },
  PARTICIPANT_EXIT: {
    actions: ['removePlayer'],
    target: '#cancelGame'
  },
  PARTICIPANT_DISCONNECT: {
    // Handle temporary disconnection
  },
  PARTICIPANT_RECONNECT: {
    // Handle reconnection
  }
}

Accessing Participant Information: The AppM receives participant information through the PARTICIPANT_ENTER event payload. You can store any public participant data in your AppM context:

// In your action
addPlayer: assign({
  players: ({ context, event }) => {
    const { participantId, participantInfo } = event;
    const newPlayer = {
      id: participantId,
      name: participantInfo.name,
      // Store any other public participant data you need
      joinedAt: new Date().toISOString()
    };
    return [...context.players, newPlayer];
  }
})

Note: The AppM does not have direct access to AEM internals. All participant data must be captured through events and stored in your own context.

Spectator Events

on: {
  SPECTATOR_ENTER: {
    target: 'gameInProgress'
    // v5 note: internal transitions are the default (v4 had `internal: true`)
  }
}

Custom Game Events

on: {
  MOVE: [
    {
      guard: 'validMove',
      actions: ['updateBoard'],
      target: 'checkForOutcome'
    },
    {
      actions: 'handleInvalidMove',
      target: 'waitPlayerMove'
      // v5 note: internal transitions are the default (v4 had `internal: true`)
    }
  ]
}

Event Payload Structure

{
  participant: {
    id: "user-id",
    info: {
      name: "Player Name",
      avatarUrl: "https://..."
    }
  },
  participantId: "user-id",
  payload: { x: 1, y: 2 }, // Custom game data
  type: "MOVE"
}

Event Termination Patterns

Correctly terminating your AppM is critical for data integrity and billing. You must always explicitly tell Anear whether the event finished successfully or was aborted.

The "Graceful Exit" Pattern (Success)

Use this when the game is over, a winner is declared, or the event concludes naturally.

gameOver: {
  // 1. Perform any app-specific cleanup first (e.g., save stats)
  // 2. Explicitly tell Anear the event is successfully closed
  entry: ['saveGameStats', 'closeEvent'],

  // 3. Mark the state as final to stop the machine
  type: 'final'
}

The "Abrupt Cancel" Pattern (Abort)

Use this when the host ends the game early, or a critical error occurs.

gameAborted: {
  // 1. Log or save reason for abortion
  // 2. Explicitly tell Anear the event is canceled (not billed/counted as success)
  entry: ['logAbortReason', 'cancelEvent'],

  // 3. Mark the state as final
  type: 'final'
}

⚠️ The "Implicit Exit" Anti-Pattern (Avoid!)

DO NOT simply let your machine reach a final state without calling a termination method.

// BAD: Missing closeEvent() or cancelEvent()
someState: {
  type: 'final'
}

Consequences:

  • The AEM will detect this as an improper shutdown.
  • A LOUD ERROR will be logged to your console.
  • The event will be forcibly transitioned to canceled.
  • The event will be removed from "Active Events" lists, but will not be recorded as a successful completion.

Actions and Guards

actions: {
  addPlayer: assign(({ context, event }) => ({
    playerIds: {...context.playerIds, [event.participant.id]: event.participant}
  })),
  updateBoard: assign(({ context, event }) => {
    const { x, y } = event.payload
    const newBoard = context.board.map(row => [...row])
    newBoard[x][y] = context.currentPlayerToken
    return { board: newBoard }
  })
},
guards: {
  validMove: ({ context, event }) => {
    const { payload: {x, y}, participantId } = event
    return context.board[x][y] === C.EMPTY &&
           participantId === context.playerIds[context.currentPlayerToken]
  }
}

UI Development

Anear apps render within a responsive, mobile-first container managed by the Anear Browser. To ensure a consistent and high-quality user experience, developers should follow these primary design guidelines:

  1. Target a 9:16 Aspect Ratio: All app UIs should be designed to fit a 9:16 portrait aspect ratio. The Anear Browser will ensure your app canvas scales correctly to fit the user's device, from standard phones to taller, modern screens.
  2. Design with High-Resolution Assets: Create your rasterized assets (e.g., .png, .jpg backgrounds) at a base resolution of 1080x1920 pixels. This ensures they will look crisp and clear when scaled down to fit a variety of mobile screen sizes.
  3. Compose Your UI: Instead of using a single, flattened background image, compose your UI from separate elements. Use a high-resolution scenic background and layer text, grids, buttons, and other UI elements on top using HTML and CSS. This approach is more performant, scalable, and maintainable.

Pug Template System

Template Structure

//- GameView.pug
extends Layout.pug

block content
  +GameBoard(clickable)

block footer
  include PlayerInfo.pug

Available Variables

// In Pug templates:
app          // Current context (game state)
participants // Map of all participants
meta         // Current state metadata
props        // props passed from the meta block (e.g., `props.title`)
cdnImg()     // CDN image helper
action()     // Action helper for generating action payloads

Interactive Elements

// Action triggers
anear-action.sq(payload=(clickable ? action({MOVE: {x, y}}) : undefined))

// Navigation
anear-nav.btn(action='join') Join Event
anear-nav.back-button(action='back') Back

// QR Code generation
anear-qr-code.qr-code
anear-qr-code.qr-code(expand)  // With expand-on-click functionality

// Form submissions
anear-form(action="SAVE_QUESTION_TEXT")
  textarea(name="text")
  button(type="submit") Submit

Action Payload Tokenization

anear-action payloads are tokenized by JSAPI for participant-private renders. This is a platform-level security hardening that prevents clients from seeing or mutating app-specific action payload JSON.

How it works:

  • In Pug, you still write payload=action(...) exactly as before.
  • During private render publishing, JSAPI replaces that payload JSON with a short opaque token.
  • AEM stores an ephemeral per-participant lookup table for the current render and resolves the token back to the original { EVENT_NAME: payload }.
  • If a token is unknown/expired/not in the current lookup, AEM drops the ACTION and does not forward it to your AppM.

Important implications:

  • Duplicate buttons with the same semantic action get different tokens and still resolve to the same AppM event/payload.
  • anear-action without payload remains inert/non-clickable in ABR (no action publish path).
  • Form submissions (anear-form) are JSON-based. Optionally set data-form-token=formAction('EVENT_NAME') in Pug so ABR sends { key, form_data } and AEM validates the token against the same lookup maps as action().

Best practice:

  • Keep AppM guards (participantId, turn checks, range checks, etc.) even with tokenization. Tokenization protects transport trust boundaries; guards enforce game/business rules.

Conditional Rendering

-
  let clickable = participant.info.id === app.playerIds[app.currentPlayerToken]
  let isJoinClickable = meta.state === "live.registration.waitForOpponentToJoin" &&
                       meta.viewer === "spectators"

if isJoinClickable
  anear-nav.ft-player(action='join')
    +FooterPlayerContent()
else
  .ft-player
    +FooterPlayerContent()

Anear WebComponents

Anear provides custom WebComponents that handle interactive behaviors like actions, navigation, form submissions, and QR code display. These components replace the older data-anear-* attribute pattern and provide a more robust, maintainable approach to building interactive UIs.

Available Components

<anear-action> - Triggers custom game actions

// Basic action
anear-action.btn.btn-primary(payload=action("START_GAME")) Start Game

// Action with data payload
anear-action.square(payload=action({MOVE: {x: 1, y: 2}}))

// Conditional action (use undefined to disable)
anear-action.sq(payload=(clickable ? action({MOVE: {x, y}}) : undefined))

<anear-help> - Opens a standard ABR help modal from inline template content

// Standard outlined help trigger with shell-owned modal
anear-help(label="Question writing help" title="Question Writing Tips")
  h3 Question Writing Tips
  p Keep the tone playful and easy to scan quickly.
  ul
    li Use one clearly correct answer.
    li Keep distractors plausible.
    li Avoid insider-only references if most guests would be lost.

// Optional filled icon variant
anear-help(label="Prize help" title="Prize Tips" icon-style="filled")
  h3 Prize Tips
  p Explain the prize clearly and keep expectations simple.

anear-help behavior:

  • ABR renders the standard clickable ? trigger for you.
  • The element's inner HTML is hidden from normal layout and shown inside a scrollable ABR modal.
  • The modal is shell-owned, so you do not need AppM state such as SHOW_HELP_MODAL / HIDE_HELP_MODAL.
  • Use presentational content only in v1: headings, paragraphs, lists, and static markup.
  • Do not rely on nested anear-action, anear-form, or other interactive controls inside help content in v1.

Optional: app-owned modal chrome (header, icon, close)

By default, ABR shows its own modal header (title from the title attribute and a shell close control) above your captured HTML. If you want a single header row defined entirely in templates—e.g. app icon, title, and a close control that matches host setup—wrap the help body in markup that ABR recognizes as layout-owned chrome:

  • Put data-anear-help-layout on the root wrapper of that chrome (empty value is fine). ABR detects this marker in the sanitized HTML string and omits the default React header so you are not doubled up.
  • Provide a close affordance using data-anear-help-close on a control (for example button type="button" or a link-styled control). ABR closes the dialog when that node is clicked. The optional class .anear-help-chrome-close is also recognized; you can drop it if you prefer your own classes.
  • Keep long copy in a dedicated scroll region (convention: .anear-help-chrome-body) so the header stays fixed while only the body scrolls. Style .anear-help-chrome, .anear-help-chrome-header, and .anear-help-chrome-body in your app’s CSS; ABR still supplies the outer dialog shell, focus handling, and backdrop.
  • The anear-help web component does not change: it only captures inner HTML and opens the modal. All chrome is ordinary template + CSS in your AppM views.
  • Ensure attributes data-anear-help-layout and data-anear-help-close survive ABR HTML sanitization for your display pipeline (they are allowlisted in the browser). If you use a Pug mixin for repeated chrome, keep the mixin next to your other host views for consistency (see wedding-trivia’s helpModalChrome pattern).

Example shape (adapt classes, icon src, and close styling to your app):

anear-help(label="Landing page help" title="Landing Page Help")
  .anear-help-chrome(data-anear-help-layout="")
    header.anear-help-chrome-header
      .anear-help-chrome-brand
        img.anear-help-chrome-app-icon(src=yourAppIconUrl, alt="", role="presentation")
        h2.anear-help-chrome-title Landing Page Help
      button.anear-help-chrome-close(type="button", data-anear-help-close="", aria-label="Close help")
        span(aria-hidden="true") ×
    .anear-help-chrome-body
      p Your scrolling help copy, lists, and headings go here.

Anear Anchor Partial Updates

<anear-anchor> defines a named DOM target that can be updated without replacing the full event display.

Use this pattern when:

  • only a small region changes frequently (scores, turn indicator, badges),
  • you want smaller realtime payloads,
  • you want to avoid visible full-display flashing.

1) Define stable anchors in your full view

Your full render should include all anchors that later targeted updates will modify.

.player-seat
  .score
    | Score:
    anear-anchor(name=`seat_${participant.info.id}_score`) #{app.players[participant.info.id].score}

  .tricks
    | Tricks:
    anear-anchor(name=`seat_${participant.info.id}_tricks`) #{app.players[participant.info.id].tricksWon}

2) Send targeted updates from meta.eachParticipant

meta: {
  eachParticipant: (context, event) => {
    if (!event.participantId) return []
    const pid = event.participantId
    const player = context.players?.[pid] || {}

    return [
      {
        participantId: pid,
        anchor: `seat_${pid}_score`,
        content: String(player.score ?? 0),
        contentType: 'text',   // updates textContent
        type: 'replace'
      },
      {
        participantId: pid,
        anchor: `seat_${pid}_tricks`,
        content: String(player.tricksWon ?? 0),
        contentType: 'text',
        type: 'replace'
      }
    ]
  }
}

3) HTML vs text targeted updates

  • contentType: 'html' (default): use for rendered partial markup.
  • contentType: 'text': use for scalar text values (scores, counters, labels).
  • type: 'replace' (default): safest for counters.
  • type: 'append': useful for log/transcript-style anchors.
// html target update (template-based)
{ participantId: pid, anchor: 'seat_status', view: 'participant/SeatStatusBadge', contentType: 'html', type: 'replace' }

// text target update (direct content)
{ participantId: pid, anchor: 'seat_score', content: String(score), contentType: 'text', type: 'replace' }

Important constraints

  • Anchors must already exist in the live DOM when the targeted update arrives.
  • Keep anchor names deterministic (seat_${participantId}_score).
  • Prefer mixins/partials so full-render anchor structure remains consistent across states.

<anear-nav> - Handles navigation actions (ABR commands)

// Back navigation
anear-nav.back-button(action='back') Back

// Join event
anear-nav.btn(action='join') Join Event

// Exit with ABR default confirmation behavior (participants in announce/live are prompted)
anear-nav.btn(action='exit') Exit

// Force a confirmation prompt regardless of state (useful for destructive exits)
anear-nav.btn(action='exit' prompt) Exit

// Custom confirmation prompt text + custom button labels
anear-nav.btn(
  action='exit'
  prompt='Closing this room will remove everyone. Continue?'
  stay='No, Keep Room Open'
  exit='Yes, Close Room'
) Close Room

// `reset` is supported as an alias of `stay` (parity with <anear-timeout>)
anear-nav.btn(action='exit' prompt='Leave this event?' reset='No, Stay' exit='Yes, Exit') Exit

// Never prompt (hard exit)
anear-nav.btn(action='exit' no_prompt) Exit Now

anear-nav exit prompt behavior:

  • Default: participants/hosts in announce or live get ABR exit confirmation; spectators/non-playable states do not.
  • prompt: forces confirmation. If prompt has text, ABR uses that text in the dialog.
  • stay: custom "do not exit" button label.
  • exit: custom "confirm exit" button label.
  • reset: alias for stay (for consistency with <anear-timeout> naming).
  • no_prompt / no-prompt: disables confirmation and exits immediately.

<anear-form> - Handles form submissions

anear-form(action="SAVE_QUESTION_TEXT")
  textarea(name="text" placeholder="Enter question...")
  button(type="submit") Save Question

anear-form serializes native input, select, and textarea fields plus supported custom form components such as anear-counter, anear-choice-group, anear-file-upload, and anear-image-upload. Custom upload components expose their values through component properties, so ABR intentionally skips their internal hidden inputs and reads the component's value, filenameValue, and, for positioned images, positionValue.

<anear-drop-down> - Select control with optional auto-submit

-
  const items = [
    { value: "template_first_date", label: "First date", tags: ["easy", "couple"] },
    { value: "template_proposal", label: "Proposal story", tags: ["personal"] }
  ]

anear-form(action="SAVE_SETUP")
  anear-drop-down(
    name="suggestedTemplateId",
    items=JSON.stringify(items),
    placeholder="Select a canned question...",
    submit-mode="auto",
    submit-name="editorAction",
    submit-value="template",
    submit-on-empty="false"
  )

anear-drop-down writes the selected value into a hidden input named by name. Set submit-mode="auto" to submit the closest anear-form whenever a non-empty selection is made. submit-name and submit-value act like the clicked submit button so your AppM can branch on the editor action. submit-on-change="true" is still recognized for older templates, but prefer submit-mode.

<anear-file-upload> - Direct file/image upload through ABR presigned POST plumbing

anear-form(action="SAVE_QUESTION_EDITOR")
  .question-media-preview#question-photo-preview(data-preview-img-class="question-photo")
    if app.newQuestion.imageUrl
      img.question-photo(src=app.newQuestion.imageUrl, alt="Question photo preview")
    else
      .question-photo-placeholder Photo Preview

  anear-file-upload(
    name="uploadedImageUrl",
    filename-field="uploadedImageFilename",
    purpose="content_image",
    accept="image/jpeg,image/png,image/webp,image/gif",
    optimize-images="true",
    max-edge-px="640",
    submit-on-success="true",
    submit-name="editorAction",
    submit-value="apply",
    preview-target="#question-photo-preview",
    preview-alt="Question photo preview",
    label="Upload Photo",
    placeholder=(app.newQuestion.imageFilename || "No file uploaded yet.")
  )

Use anear-file-upload when you need a generic upload, or an image upload without crop/edit UI. ABR asks ANAPI for an event media presigned POST, uploads directly to S3, then writes the final CDN URL into the form field named by name.

Useful attributes:

  • name: field that receives the final CDN URL.
  • filename-field: optional field that receives the display filename.
  • purpose: upload purpose sent to ANAPI, e.g. content_image or event_headshot.
  • accept: native file input accept list.
  • optimize-images: defaults to on for image uploads. Set false, off, or 0 to upload original pixels.
  • max-source-mb, max-edge-px, passthrough-max-kb, webp-quality: tune client image preparation.
  • preview-target: selector for a nearby preview container or img. ABR scopes lookup to the closest anear-form first.
  • filename-target: optional selector for showing the selected/display filename outside the status line.
  • submit-on-success: if true, ABR submits the closest anear-form after upload using submit-name and submit-value.

<anear-image-upload> - Image upload with optional crop/position editor

// Plain optimized image upload, no crop metadata
anear-image-upload(
  name="uploadedPhotoUrl",
  filename-field="uploadedPhotoFilename",
  purpose="content_image",
  accept="image/jpeg,image/png,image/webp",
  preview-target="#photo-preview",
  label="Upload Photo"
)

anear-image-upload uses the same presigned POST and image optimization pipeline as anear-file-upload, but adds optional crop modes. Its generated buttons use standard btn btn-secondary and btn btn-link classes so app CSS can style them consistently.

Use crop-mode="position" when the uploaded file should stay intact, but the app wants hidden display metadata for positioning the image inside a fixed viewport (for example, a question card backdrop):

anear-image-upload(
  name="uploadedImageUrl",
  filename-field="uploadedImageFilename",
  value=(app.newQuestion.imageUrl || ""),
  filename-value=(app.newQuestion.imageFilename || ""),
  position-field="uploadedImagePosition",
  position-value=JSON.stringify(app.newQuestion.imagePosition || null),
  purpose="content_image",
  crop-mode="position",
  crop-aspect="1.35",
  editor-title="Adjust Question Photo",
  adjust-label="Adjust Photo",
  submit-on-success="true",
  submit-name="editorAction",
  submit-value="apply",
  preview-target="#question-photo-preview",
  preview-alt="Question photo preview",
  label=(app.newQuestion.imageUrl ? "Replace Photo" : "Upload Photo")
)

crop-mode="position" submits JSON like {"x":50,"y":72,"zoom":1.2} in the field named by position-field (default: uploadedImagePosition). Your AppM stores that metadata and your template/CSS applies it, for example with CSS variables or object-position/transform. This is useful when a single source image needs to be composed differently in a particular UI frame.

Use value only when you intentionally want the existing image URL to be the component's current form value, such as an editor that can reopen and adjust an existing question photo. If a normal "Save" should not re-submit the existing URL as a fresh upload signal, render the preview image yourself and omit value; the component will still set name when a new upload completes.

Use crop-mode="upload" when the crop should be baked into the uploaded asset itself (for avatars/headshots that appear in many places):

anear-image-upload(
  name="uploadedBrideInfoHeadShotUrl",
  filename-field="uploadedBrideInfoHeadShotFilename",
  purpose="event_headshot",
  crop-mode="upload",
  crop-aspect="1",
  editor-title="Adjust Bride Headshot",
  submit-on-success="true",
  submit-name="configAction",
  submit-value="apply",
  preview-target="#bride-headshot-preview",
  preview-alt="Bride headshot preview",
  label=(app.eventConfig.brideInfo.headShotUrl ? "Replace headshot" : "Upload headshot")
)

crop-mode="upload" renders the selected crop to a canvas, creates a new image File, then runs the normal resize/WebP/JPEG optimization and upload flow. It does not submit position metadata, because the CDN image itself is already cropped.

Current anear-image-upload attributes include all anear-file-upload attributes plus:

  • crop-mode: omitted/false for plain image upload, position for hidden x/y/zoom metadata, or upload for a baked cropped upload.
  • crop-aspect: crop frame aspect ratio, e.g. 1 for square headshots or 1.35 for a card photo region.
  • position-field / position-value: used by crop-mode="position".
  • value: existing image URL for initial preview display.
  • filename-value: existing display filename.
  • editor-title: crop modal title.
  • adjust-label: text for the adjust button in position mode.

If your app sanitizes or post-processes HTML before ABR sees it, make sure these custom element attributes survive. ABR's built-in sanitizer already allowlists the current upload attributes (filename-value, position-field, position-value, crop-mode, crop-aspect, editor-title, adjust-label, image optimization settings, submit settings, preview targets, etc.).

<anear-timeout> - ABR-local action timeout wrapper (turn timers / “are you still playing?” prompts)

Wrap one or more <anear-action> and/or <anear-form> elements to enforce a local timer in the ABR:

  • After (actionMs), the ABR disables the wrapped actions/forms and shows a prompt.
  • If the user does not respond within (modalMs), the ABR publishes an ACTION_TIMEOUT action on the event action channel (so your AppM can decide what to do).

Attributes:

  • duration="actionMs,modalMs": two comma-separated integers in ms (e.g. "90000,30000").
  • prompt="...": optional prompt text (defaults to “Do you still want to participate?”).
  • reset="...": optional label for the YES/reset button (defaults to “Yes”).
  • exit="...": optional label for the NO/exit button (defaults to “No”).
  • id="...": optional identifier included as timeoutId in the timeout detail.

Example (turn timer for the active player):

//- Wrap interactive actions so ABR can gate them on timeout
anear-timeout#moveActionTimeout(duration=`${app.C.MOVE_TIMEOUT_MSECS},${app.C.MOVE_TIMEOUT_MODAL_MSECS}` prompt='Are you still playing?' reset='YES, I NEED MORE TIME' exit='EXIT')
  anear-action.btn(payload=action('MAKE_MOVE')) Make move

Corresponding AppM handler (receives ACTION_TIMEOUT on the actions channel):

on: {
  ACTION_TIMEOUT: {
    guard: 'isCurrentPlayer',
    actions: ['setTimeoutForfeitWinner'],
    target: '#gameIsEnding'
  }
}

<anear-qr-code> - Displays event QR codes

// Basic QR code display
anear-qr-code.qr-code

// QR code with expand-on-click functionality
anear-qr-code.qr-code(expand)

<anear-autofocus> - Autofocus the first <input>/<textarea> inside

// Focus the input and place the caret at the end
anear-autofocus(behavior='end')
  textarea(name='note')= app.noteText

Back-compat: data-anear-autofocus="end" on the input/textarea is also supported.

<anear-autoscroll> - Auto-scroll a scrollable container to bottom as content grows

anear-autoscroll(behavior='smooth')
  .messagesContainer
    each msg in app.messages
      .message= msg

Back-compat: data-anear-autoscroll="smooth" on the scroll container is also supported.

<anear-user-avatar> - Renders the current viewer’s avatar

anear-user-avatar.avatar

<anear-counter> - Numeric counter with +/- controls (includes a hidden <input> for <anear-form>)

anear-form(action="SET_ROUNDS")
  anear-counter(name="rounds" value="3" min="1" max="20" step="1")
  button(type="submit") Save

<anear-choice-group> - Single-select choice group (radio-like), with form integration

Children with a value (or data-choice-value) attribute become clickable choices; the selected choice gets a selected class and the value is stored in a hidden <input name="...">.

anear-form(action="SET_THEME")
  anear-choice-group(name="theme" value="desert")
    .choice(value="desert") Desert
    .choice(value="ocean") Ocean
    .choice(value="forest") Forest
  button(type="submit") Save

<anear-app> - Embed a child Anear app (Shadow DOM isolated)

// Child app slug is required; options (JSON) are passed as initial_context to the child AppM
anear-app(name="event-chat" options='{"widget":"collapsed"}')

WebComponent Styling Considerations

WebComponents are layout-transparent by design - they should behave exactly like native HTML elements with the same CSS classes. However, there's one important consideration for CSS:

The Display Property Requirement

WebComponents default to display: inline, which can conflict with CSS classes that expect different display values (like block, flex, or inline-block). To ensure your WebComponents participate correctly in layout, you must explicitly set the display property in your CSS to match what the class would normally apply.

Best Practice Pattern:

/* Match the display property that your classes expect */
anear-action.btn,
anear-nav.btn {
  display: inline-block;  /* .btn is typically inline-block */
}

anear-action.bidding-die-button {
  display: flex;  /* .bidding-die-button uses flex layout */
}

anear-nav.back-button {
  display: inline-block;  /* .back-button is typically inline-block */
}

anear-action.bidding-submit-btn {
  display: block;  /* For buttons in flex containers with justify-content: center */
}

Why This Matters:

When you convert from native elements (like <button> or <div>) to WebComponents, the native elements have default display values that your CSS may rely on. For example:

  • <button> defaults to display: inline-block
  • <div> defaults to display: block
  • Elements in flex containers may need display: flex or display: block

By explicitly setting display for your WebComponent classes, you ensure they behave identically to the native elements they replace, without requiring changes to your existing CSS rules.

Example: Converting a Button

// Before (native button with deprecated pattern)
button.btn.btn-primary(data-anear-action=action("SUBMIT")) Submit

// After (WebComponent)
anear-action.btn.btn-primary(payload=action("SUBMIT")) Submit
/* Add this to ensure the WebComponent behaves like a button */
anear-action.btn {
  display: inline-block;  /* Matches button's default display */
}

/* Your existing .btn styles continue to work */
.btn {
  padding: 1rem 2rem;
  border-radius: 0.5rem;
  /* ... other styles ... */
}

Layout Transparency

The WebComponents are designed to be layout-transparent - they don't impose any default styles that would interfere with your CSS. This means:

  • ✅ You can apply any CSS classes to WebComponents
  • ✅ Flexbox, Grid, and other layout systems work normally
  • ✅ You just need to set display explicitly for each WebComponent class
  • ✅ All other CSS properties (padding, margin, colors, etc.) work as expected

Troubleshooting Layout Issues

If you experience layout misalignments after converting to WebComponents:

  1. Check the display property: Ensure your CSS explicitly sets display for the WebComponent class
  2. Match native element behavior: If the original element was a <button>, the WebComponent should use display: inline-block or display: block depending on your layout needs
  3. Flex container centering: For elements in flex containers with justify-content: center, use display: block on the WebComponent
  4. Text alignment: Ensure text-align: center is set if you need centered text inside the component

CSS and Styling

The Anear Rendering Container

Anear apps are not rendered directly in the browser's full viewport. Instead, they are displayed inside a responsive container within the Anear Browser application. This container maintains a strict 9:16 aspect ratio and scales to fit devices. The ABR width-fits on tall phones and height-fits on wider screens (e.g., iPad/desktop), with a container max width of 720px and container-type: inline-size enabled for container queries. This has important implications for how you should write your CSS.

Use Container-Relative Units for Responsive Sizing

Because your app lives inside a container, viewport-relative units like vw or vh can cause incorrect scaling. Use Container Query Units and percentages tied to the container instead, targeting a base width of ~720px.

  • % for Layout: Use percentages for positioning elements within the game container so layout scales with the canvas.
  • cqw for Fonts/Elements: Use cqw for font-size and scalable element sizes. 1cqw = 1% of the container width.
  • Avoid viewport units: Avoid vh/vw inside the game canvas; prefer % and cqw.
  • Prefer relative assets: Set background-size: contain and use object-fit: contain for images.
/*
  Anear apps are displayed in a responsive container that maintains a 9:16
  aspect ratio. Use container-relative units (cqw) and percentages (%)
  to ensure your UI scales correctly.
*/
.game {
  position: relative;
  width: 100%;
  height: 100%;
  background-image: url("../assets/images/Background.png");
  background-size: contain;
  background-repeat: no-repeat;
  background-position: center;
  display: flex;
  flex-direction: column;
}

/* Position elements with percentages */
.game-grid {
  position: absolute;
  top: 18.4%;
  left: 4.3%;
  width: 91.4%;
  height: 51.4%; /* Calculated to keep the grid square */
}

/* Scale fonts with the container's width using cqw */
.game-title {
  font-family: "FooFont", "Poppins", sans-serif;
  font-size: 11cqw;
  font-weight: 800;
  color: white;
}

Asset Management

Automatic CDN Upload

The anear-js-api automatically handles asset management:

  1. CSS Upload: All CSS files in assets/css/ are uploaded to CloudFront
  2. Image Upload: All images in assets/images/ are uploaded to CloudFront
  3. Template Preloading: All Pug templates are preloaded and cached

Asset Structure

assets/
├── css/
│   └── app.css
├── fonts/
│   └── YourCustomFont.ttf
└── images/
    ├── Background.png
    ├── Grid.png
    ├── Player1.png
    ├── x.png
    ├── o.png
    └── anon_user.png

Using Assets in Templates

// CDN helper function
img(src=cdnImg('x.png') alt='X token')

// Background images in CSS
.game {
  background-image: url("../assets/images/Background.png");
}

Best Practice: For the best user experience, compose your UI from separate, high-resolution assets rather than using a single, flattened image. Use a high-quality scenic background, and layer UI elements like titles, grids, and footers on top using HTML and CSS. This creates a sharper, more scalable, and more maintainable application.

Deployment

Hosting Requirements

  • Your Responsibility: Host your Node.js application instance
  • Anear's Responsibility: Real-time communication via Ably pub/sub

Anear applications are designed to be self-hosted on your preferred platform. To connect your application to the Anear ecosystem, you will need a developer API key and an App ID. In the near future, a self-service developer portal will be available for you to register, create apps, and manage their associated zones. For now, please contact Anear Technical Staff, who will manually provision your keys and IDs via their production console.

Deployment Process

  1. Deploy your app to your preferred hosting platform
  2. Anear handles real-time messaging, CDN, and participant management
  3. Automatic asset upload occurs when your app starts

Example Deployment (GitHub Actions)

name: Deploy to Anear App

on:
  push:
    branches: [ main ]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Sync & restart on server
        uses: appleboy/ssh-action@v0.1.7
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ubuntu
          key: ${{ secrets.SSH_PRIVATE_KEY }}
          script: |
            cd ~/anearapps/your-app
            git pull origin main
            npm ci --production
            pm2 restart your-app

Example: Tic-Tac-Toe Game

Let's examine the tic-tac-toe game as a concrete example of all these concepts in action.

State Machine Structure

// StateMachine.js
const Config = {
  id: "tic-tac-toe",
  initial: 'live',
  states: {
    live: {
      initial: 'registration',
      states: {
        registration: {
          initial: "waitForHostToJoin",
          states: {
            waitForHostToJoin: {
              on: {
                PARTICIPANT_ENTER: {
                  actions: ['addXPlayer'],
                  target: 'waitForOpponentToJoin'
                }
              }
            },
            waitForOpponentToJoin: {
              entry: ['announceEvent'],
              meta: {
                eachParticipant: 'ViewableGameBoard',
                spectators: 'ViewableGameBoard'
              },
              on: {
                PARTICIPANT_ENTER: {
                  actions: ['addOPlayer'],
                  target: '#gameInProgress'
                }
              }
            }
          }
        },
        gameInProgress: {
          id: 'gameInProgress',
          meta: {
            eachParticipant: {
              view: 'PlayableGameBoard',
              timeout: calcParticipantTimeout
            },
            spectators: 'ViewableGameBoard'
          },
          states: {
            waitPlayerMove: {
              on: {
                MOVE: [
                  {
                    guard: 'validMove',
                    actions: ['updateBoard'],
                    target: 'checkForOutcome'
                  }
                ]
              }
            },
            checkForOutcome: {
              always: [
                { guard: 'isWinner', target: 'announceWinner' },
                { guard: 'isDraw', target: 'announceDraw' },
                { actions: ['nextPlayersTurn'], target: 'waitPlayerMove' }
              ]
            }
          }
        }
      }
    }
  }
}

Game Constants

// Constants.js
const Constants = {
  X_TOKEN: 'x',
  O_TOKEN: 'o',
  EMPTY: ' ',
  DEFAULT_TOKEN: 'x',
  WHOS_NEXT: {'x': 'o', 'o': 'x'},
  WINNING_COMBOS: [
    [[0,0],[0,1],[0,2]], // Top row
    [[1,0],[1,1],[1,2]], // Middle row
    [[2,0],[2,1],[2,2]], // Bottom row
    [[0,0],[1,0],[2,0]], // Left column
    [[0,1],[1,1],[2,1]], // Middle column
    [[0,2],[1,2],[2,2]], // Right column
    [[0,0],[1,1],[2,2]], // Diagonal
    [[0,2],[1,1],[2,0]]  // Diagonal
  ],
  MOVE_TIMEOUT_MSECS: 15 * 1000
}

UI Templates

//- PlayableGameBoard.pug
extends Layout.pug

block content
  -
    let clickable = participant.info.id === app.playerIds[app.currentPlayerToken]
  +GameGrid(clickable)

block footer
  include Footer.pug
//- GameGrid.pug
mixin GameGrid(clickable)
  .game-table
    each row, x in app.board
      each token, y in row
        if token === app.C.EMPTY
          anear-action.sq(payload=(clickable ? action({MOVE: {x, y}}) : undefined))
        else
          .token
            img(src=cdnImg(`${token}.png`) alt=token + ' token')

Key Development Patterns

  1. State-Driven UI: Different views for different game states
  2. Participant Roles: Different experiences for players vs spectators
  3. Real-time Updates: All participants see changes immediately
  4. Timeout Management: Automatic turn management
  5. Asset Integration: Images and CSS automatically served from CDN

Best Practices

State Machine Design

  • Use hierarchical states for complex flows
  • Keep actions pure and focused
  • Use guards for validation logic
  • Leverage meta for UI management

Event Handling

  • Handle all AEM events appropriately
  • Validate custom events thoroughly
  • Use internal transitions for UI-only updates
  • Implement proper error handling
  • Always explicitly call closeEvent() or cancelEvent() before terminating

Live Idle + Save/Restore Patterns

Recent AEM behavior supports long-running live events where AppM decides what to do when a room is quiet.

  • LIVE_ACTION_IDLE is sent to AppM when live activity has been idle for TIMEOUT_MSECS.LIVE_IDLE.
  • This is a notification, not an immediate fatal timeout. Your AppM can:
    • ignore it and continue live,
  • call anearEvent.saveEvent(context, { saveMode: 'overwrite' }) to persist state and continue,
  • call anearEvent.pauseEvent(context, { saveMode: 'overwrite' }) to persist and quiesce the event machine,
    • or call closeEvent() / cancelEvent() to end.
  • AEM tracks consecutive live-idle intervals and can auto-close after DEAD_MAN_SWITCH.MAX_CONSECUTIVE_LIVE_IDLE if no qualifying external activity occurs.
on: {
  LIVE_ACTION_IDLE: [
    { guard: 'shouldCloseNow', actions: ['closeEvent'] },
    { guard: 'shouldQuiesce', actions: ['pauseEventForLater'] },
    { actions: ['saveProgress'] } // optional in-place snapshot save
  ]
}
actions: {
  saveProgress: ({ context }) => anearEvent.saveEvent(context, { saveMode: 'overwrite' }),
  pauseEventForLater: ({ context }) => anearEvent.pauseEvent(context, { saveMode: 'overwrite' })
}

Rehydrate / wake-up model

  • When an AEM is not currently running for an event, ANAPI can publish LOAD_EVENT to the app lifecycle channel.
  • ACSM starts AEM in rehydrate mode and restores the AppM from the latest persisted XState snapshot.
  • No explicit RESUME payload is required. After restore, the AppM is already back in its prior state and can continue handling normal events such as TRANSITION, HOST_ENTER, or PARTICIPANT_ENTER.
  • This enables hosted/open-house scenarios where users return after idle periods and the app resumes from saved state.

UI Development

  • Design mobile-first responsive layouts
  • Use semantic HTML and accessible patterns
  • Leverage CSS Grid/Flexbox for layouts
  • Test across different screen sizes

Performance

  • Minimize context updates
  • Use efficient template rendering
  • Optimize asset sizes
  • Implement proper caching strategies

Conclusion

The Anear platform provides a powerful foundation for building location-based multiplayer experiences. By understanding the state machine architecture, event handling patterns, and UI development workflow, developers can rapidly create engaging games and activities that scale to hundreds of participants.

The key is to focus on your game logic and user experience while letting the anear-js-api handle the complex real-time communication, asset management, and participant coordination.

For more examples and advanced patterns, refer to the tic-tac-toe implementation and explore the anear-js-api documentation.