Anear Developer Guide
Table of Contents
- Overview
- Architecture
- Anear Event Stages
- Participant Presence Event Flow
- The anearEvent Object
- XState Integration
- Event Handling
- UI Development
- Asset Management
- Deployment
- 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
RENDEREDevent so everyone actually sees it, then explicitly end the event withcloseEvent()orcancelEvent().
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 hostmeta.allParticipants: one shared view for everyone who is an active participantmeta.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_EXITSPECTATOR_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_DISCONNECTas a temporary absence. - Treat
PARTICIPANT_EXITas “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 sometimesPARTICIPANT_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-apiexchanges 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-Keyheader)
Required environment variables
These are the environment variables your App/JSAPI process must provide:
ANEARAPP_API_KEY: developerapi_keyANEARAPP_API_SECRET: developersecretANEARAPP_API_VERSION: usuallyv1ANEARAPP_API_URL(optional): base URL for the developer API (defaults tohttps://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()oranearEvent.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
finalstate 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 eitherHOST_*orPARTICIPANT_*events to your AppM.
Participant State Detection:
- New vs Reconnect: The AEM uses the
participantExistsguard to check if a participant ID exists incontext.participants. If true,PARTICIPANT_ENTERtriggers a reconnect flow rather than a new join. - Reconnect vs Update: When
PARTICIPANT_UPDATEis received, ifevent.data.typeis undefined, the AEM treats it as an implicit reconnect (the browser detected the user left and returned). Ifevent.data.typeis 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 theirparticipantMachineinstance, preserving their participant data slot withactive: false(idle state). - Active/Idle State Management: Participants in open house events have an
activestate tracked in theirparticipantInfo:active: true- Participant is connected and has an activeparticipantMachineactive: false- Participant has exited and their machine has been removed (idle state)- Active state is set to
trueon entry, reconnect, and geo-location updates - Active state is set to
falseon disconnect or exit (for open house events only)
- Automatic Reconnect: Subsequent
PARTICIPANT_ENTERorPARTICIPANT_UPDATEevents for an idle participant (one that exists incontext.participantsbut has no machine) will trigger machine recreation. The participant's machine is recreated, their active state is set totrue, andPARTICIPANT_RECONNECT(orHOST_RECONNECT) is sent to the AppM instead of treating them as new participants. - Display Filtering: Only active participants (those with
active: trueand a validparticipantMachine) receive display events. Idle participants are automatically excluded fromeachParticipantandallParticipantsdisplays. - AppM Handling: Your AppM should handle
PARTICIPANT_RECONNECTevents 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:
- Distinguish between new joins and reconnects: Use
PARTICIPANT_ENTERfor new participants,PARTICIPANT_RECONNECTfor returning participants. - Update participant presence state: Track whether participants are currently present, absent, or exited in your AppM context.
- Refresh views on reconnect: Use
PARTICIPANT_RECONNECTevents to update UI and restore participant state, especially important for OPEN_HOUSE events. - Handle both PARTICIPANT_UPDATE scenarios: Be prepared for
PARTICIPANT_UPDATEto 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:
contentTypedefaults to'html'typedefaults 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
- AppM transitions to state with
meta: {} - Displays are rendered to participants
- AEM sends
RENDEREDwhen all displays complete - 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:
- Individual responses: Each participant triggers your chosen event (e.g.,
ANSWER) - All responded: Timer is cancelled, last ACTION has
finalAction: trueflag - Timeout reached: Timer expires,
ACTIONS_TIMEOUTsent withnonResponderIds: ['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_TIMEOUTevent (timeout expired)
- An ACTION with
- Use
finalAction: trueflag on the last ACTION event to detect when all have responded - Use
ACTIONS_TIMEOUTevent to handle timeout scenarios - The event type itself (
ACTIONS_TIMEOUT) indicates a timeout occurred (notimedOutflag needed) - Important: When handling the final ACTION with
finalAction: true, suppress display events in youreachParticipantfunction 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:
- Wrap interactive controls in the participant view with
<anear-timeout>. - On timeout, the browser emits a normal action payload (commonly mapped to
ACTION_TIMEOUT). - 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:
- 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.
- Design with High-Resolution Assets: Create your rasterized assets (e.g.,
.png,.jpgbackgrounds) 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. - 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-actionwithout payload remains inert/non-clickable in ABR (no action publish path).- Form submissions (
anear-form) are JSON-based. Optionally setdata-form-token=formAction('EVENT_NAME')in Pug so ABR sends{ key, form_data }and AEM validates the token against the same lookup maps asaction().
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-layouton 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-closeon a control (for examplebutton type="button"or a link-styled control). ABR closes the dialog when that node is clicked. The optional class.anear-help-chrome-closeis 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-bodyin your app’s CSS; ABR still supplies the outer dialog shell, focus handling, and backdrop. - The
anear-helpweb 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-layoutanddata-anear-help-closesurvive 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’shelpModalChromepattern).
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
announceorliveget ABR exit confirmation; spectators/non-playable states do not. prompt: forces confirmation. Ifprompthas text, ABR uses that text in the dialog.stay: custom "do not exit" button label.exit: custom "confirm exit" button label.reset: alias forstay(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_imageorevent_headshot.accept: native file input accept list.optimize-images: defaults to on for image uploads. Setfalse,off, or0to 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 orimg. ABR scopes lookup to the closestanear-formfirst.filename-target: optional selector for showing the selected/display filename outside the status line.submit-on-success: if true, ABR submits the closestanear-formafter upload usingsubmit-nameandsubmit-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,positionfor hidden x/y/zoom metadata, oruploadfor a baked cropped upload.crop-aspect: crop frame aspect ratio, e.g.1for square headshots or1.35for a card photo region.position-field/position-value: used bycrop-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 inpositionmode.
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_TIMEOUTaction 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 astimeoutIdin 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 todisplay: inline-block<div>defaults todisplay: block- Elements in flex containers may need
display: flexordisplay: 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
displayexplicitly 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:
- Check the display property: Ensure your CSS explicitly sets
displayfor the WebComponent class - Match native element behavior: If the original element was a
<button>, the WebComponent should usedisplay: inline-blockordisplay: blockdepending on your layout needs - Flex container centering: For elements in flex containers with
justify-content: center, usedisplay: blockon the WebComponent - Text alignment: Ensure
text-align: centeris 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 thegamecontainer so layout scales with the canvas.cqwfor Fonts/Elements: Usecqwforfont-sizeand scalable element sizes.1cqw= 1% of the container width.- Avoid viewport units: Avoid
vh/vwinside the game canvas; prefer%andcqw. - Prefer relative assets: Set
background-size: containand useobject-fit: containfor 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:
- CSS Upload: All CSS files in
assets/css/are uploaded to CloudFront - Image Upload: All images in
assets/images/are uploaded to CloudFront - 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
- Deploy your app to your preferred hosting platform
- Anear handles real-time messaging, CDN, and participant management
- 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
- State-Driven UI: Different views for different game states
- Participant Roles: Different experiences for players vs spectators
- Real-time Updates: All participants see changes immediately
- Timeout Management: Automatic turn management
- 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()orcancelEvent()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_IDLEis sent to AppM when live activity has been idle forTIMEOUT_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.
- or call
- AEM tracks consecutive live-idle intervals and can auto-close after
DEAD_MAN_SWITCH.MAX_CONSECUTIVE_LIVE_IDLEif no qualifying external activity occurs.
Recommended AppM handling
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_EVENTto the app lifecycle channel. - ACSM starts AEM in rehydrate mode and restores the AppM from the latest persisted XState snapshot.
- No explicit
RESUMEpayload is required. After restore, the AppM is already back in its prior state and can continue handling normal events such asTRANSITION,HOST_ENTER, orPARTICIPANT_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.