A shared light you can touch from anywhere.
rgem.io is an ongoing project exploring shared presence across distance — how simple, tangible interactions can connect people who aren't in the same room.
Its current form is a real-time collaborative light grid: sixteen colored cells, synchronized across web browsers and multiple custom-built hardware devices. Each device is battery-powered and WiFi-connected — fully wireless, fully mobile — navigating real constraints around power, connectivity, and reliability.
The system is live. Tap a cell in the web demo right now and physical LEDs on devices in Brooklyn light up. Every connected client — web or hardware — sees the same state, instantly.
The premise started with a question about physical proximity: the way shared objects create presence between people. A candle burning in two rooms. A stone split in half and carried apart. The first working version was an anniversary gift — a small physical object on each of our desks that we could touch to signal each other across distance. No words, no notifications, just shared light.
- Open app.rgem.io in two or more browser windows
- Select the same rgem (e.g. "default") in both and hit Connect
- Tap any cell in one window and watch the other update in real time
This is an active project. The web frontend and serverless backend are deployed and fully functional. The hardware component — a custom-built device using an Adafruit NeoTrellis keypad and Adafruit Feather M0 WiFi (ATSAMD21 + ATWINC1500), modified with battery power — is manufactured and operational. The codebase can be read, reviewed, and understood as-is, but the repository is not yet self-contained enough to reproduce from scratch.
Known gaps for reproducibility:
- AWS infrastructure — Deployment requires a pre-existing AWS account with Route 53 hosted zones, ACM certificates, and configured credentials. These are referenced by
samconfig.toml(gitignored) but not provisioned by the repo itself. - WINC1500 firmware tooling — The hardware device uses an Atmel WINC1500 WiFi module with upgraded firmware and a custom WiFi provisioning page. The x86-specific scripts and binary tools used to flash the module are not included in this repo.
- Patched WiFi101_Generic library — The device firmware depends on a custom-patched version of the WiFi101_Generic Arduino library, which is not published or included here.
Contributions and questions are welcome — see CONTRIBUTING.md.
Hardware RGem App
(NeoTrellis + ATSAMD21 + ATWINC1500) (React)
| |
| WebSocket | WebSocket
v v
┌──────────────────────────────────────┐
│ AWS Serverless Backend │
│ Lambda · API Gateway · DynamoDB │
└──────────────────────────────────────┘
The system has three components:
- RGem App (
app/) — React + TypeScript + Vite web app. Connects to the backend over WebSocket, renders the 4x4 grid, and sends click/double-click events. - Landing Page (
landing/) — Static HTML page served atrgem.io(prod only). Links to the app. - Backend (
backend/+template.yaml) — AWS SAM stack with WebSocket and HTTP API Gateways, Lambda functions (Node.js 20, ES modules), a shared Lambda layer, and two DynamoDB tables (connection tracking and grid state). Broadcasts state updates to all clients subscribed to the same "gem." - Hardware (
device/) — Arduino sketch for an Adafruit NeoTrellis keypad and Adafruit Feather M0 WiFi (ATSAMD21 + ATWINC1500). Connects to WiFi, communicates with the backend over WebSocket, and displays the shared grid state on its 4x4 RGB button matrix.
- A client (web or hardware) connects and sends a
hellomessage to subscribe to a named gem. - The backend responds with the current grid state (base64-encoded, 48 bytes = 16 cells x 3 RGB bytes).
- When any client clicks a cell, a
togglemessage is sent to the backend. - The backend updates the state in DynamoDB and broadcasts the new state to all subscribers.
- Every client renders the updated grid in real time.
| Component | Technologies |
|---|---|
| RGem App | React 19, TypeScript, Vite |
| Landing Page | Static HTML |
| Backend | AWS Lambda (Node.js 20), API Gateway (HTTP + WebSocket), DynamoDB, SAM/CloudFormation |
| Hardware | Adafruit NeoTrellis keypad + Adafruit Feather M0 WiFi (ATSAMD21 + ATWINC1500) |
.
├── README.md
├── backend/
│ ├── gempost/ <-- HTTP route handler
│ ├── ondisconnect/ <-- WebSocket disconnect handler
│ ├── onhello/ <-- WebSocket hello/subscribe handler
│ ├── onping/ <-- WebSocket ping handler
│ ├── ontoggle/ <-- WebSocket toggle handler
│ ├── schedhb/ <-- scheduled heartbeat function
│ ├── layers/common/nodejs/ <-- shared Lambda layer (DDB, WS, gem-state utils)
│ └── update-dependencies.sh <-- updates node_modules across handlers
├── app/ <-- React + TypeScript app SPA
├── device/ <-- Arduino hardware sketches
├── infra/ <-- deployment scripts
├── landing/ <-- static landing page (prod: rgem.io)
├── samconfig.toml.example <-- SAM CLI environment config template (copy to samconfig.toml)
└── template.yaml <-- SAM template for Lambda + DynamoDB
- Node.js 20
- AWS CLI (configured with credentials)
- AWS SAM CLI
- Python 3 (required by SAM CLI)
- Arduino IDE (for hardware development only)
Three environments dev, stage, prod with their own CloudFormation stacks (e.g. rgem-dev, rgem-stage and rgem-prod) are defined by samconfig.toml.
Copy the example and fill in your AWS-specific values (Hosted Zone ID, ACM Certificate ARN):
cp samconfig.toml.example samconfig.toml
# Edit samconfig.toml — replace YOUR_HOSTED_ZONE_ID, YOUR_ACCOUNT_ID, and YOUR_CERTIFICATE_IDNote:
samconfig.tomlis gitignored because it contains account-specific infrastructure identifiers.
Run once after cloning (or to switch environments):
./configure.sh dev # or stage, prodThis generates gitignored config files (.env, app/.env, device/core/config.h) that all other scripts and builds consume.
Picking the project back up? All deploy scripts read
RGEM_ENVfrom.env. Check which environment is currently stamped before running anything:cat .envIf it's wrong, re-run
./configure.sh <env>before proceeding.
sam validate --lint./infra/scripts/deploy-backend.shNote: If the
gemStatestructure changes, clear DynamoDB tables before deploying:./infra/scripts/clear-tables.sh
./infra/scripts/deploy-app.sh./infra/scripts/deploy-landing.shTo delete all AWS resources for an environment and start fresh:
./infra/scripts/force-delete-stack.shThis empties S3 buckets, disables CloudFront, and deletes the CloudFormation stack. It reads the target environment from .env (set by configure.sh). After deletion completes, you can redeploy with steps 4, 5, and (for prod) 6.
Not all changes to template.yaml behave the same way in CloudFormation. Use the table below to choose the right action before deploying.
| Change type | Action required | Commands |
|---|---|---|
| Lambda code, env vars, memory/timeout | sam deploy only |
deploy-backend.sh |
| IAM policies, CloudFront cache/error settings | sam deploy only |
deploy-backend.sh |
| API Gateway route or integration changes | Bump Description rev in template.yaml + sam deploy |
edit template.yaml → deploy-backend.sh |
API Gateway RouteSelectionExpression |
Full stack tear-down | force-delete-stack.sh → redeploy ¹ |
gemState encoding or structure change |
Clear tables → sam deploy |
clear-tables.sh → deploy-backend.sh |
DynamoDB primary key change (gemId, connectionId) |
Full stack tear-down | force-delete-stack.sh → redeploy ¹ |
| S3 bucket name change | Manually empty old bucket → Full stack tear-down | force-delete-stack.sh → redeploy ¹ |
¹ After tear-down, follow Deployment Steps to redeploy backend, app, and (for prod) landing page.
AWS::ApiGatewayV2::Deployment resources are immutable snapshots. CloudFormation will not automatically create a new deployment when routes or integrations change — it only does so when the Deployment resource's own properties change.
Rule: whenever you modify a route key, integration URI, or RouteSelectionExpression in template.yaml, increment the Description field on the affected Deployment resource before running sam deploy:
# Before
Description: "rev: 1"
# After any route or integration change
Description: "rev: 2"This applies to both RGempadHttpApiDeployment and RGempadWSApiDeployment. Skipping this step means Lambda receives the updated code but API Gateway continues routing to the old integration.
Required when the gemState encoding or structure changes (see Key Gotchas in CLAUDE.md), or when you want a clean slate without tearing down the stack.
./infra/scripts/clear-tables.shThis clears both GEM_STATE_TABLE (gem state) and CONNECTIONS_TABLE (active WebSocket connections). Connected clients will be dropped and will reconnect automatically.
Required when CloudFormation must replace a resource that cannot be updated in-place: DynamoDB primary key changes, RouteSelectionExpression changes, S3 bucket name changes, or a stack stuck in a rollback state.
./infra/scripts/force-delete-stack.shforce-delete-stack.sh handles the two preconditions that cause a normal cloudformation delete-stack to fail:
- Non-empty S3 buckets — CloudFormation cannot delete them; the script empties them first.
- Enabled CloudFront distributions — CloudFormation cannot delete them; the script disables each distribution and waits for propagation before proceeding.
After deletion, redeploy from scratch using steps 4–6 in the Deployment Steps section above.
Navigate your browser to:
- Dev: app-dev.rgem.io
- Stage: app-stage.rgem.io
- Prod: app.rgem.io
cd app
npm run testAfter running ./configure.sh <env>, app/.env is generated with the correct VITE_WS_URL. Start the dev server:
cd app
npm run devThen open a browser to http://localhost:5173/
Remember that in React Strict Mode components intentionally render twice in development mode to help find accidental side-effects and ensure components are resilient to being mounted and unmounted. When running locally, the first WebSocket connection is expected to fail because it is closed before the connection is established.
Navigate to rgem.io (prod only). www.rgem.io should redirect to rgem.io.
The rgempad API has two separate API Gateways: one for HTTP connections (RGempadHttpApi) and one for WebSocket connections (RGempadWSApi) per environment / CloudFormation stack.
To test the WebSocket API, use wscat:
npm install -g wscatwscat -c wss://<RGempadWSApi-ID>.execute-api.<YOUR-REGION>.amazonaws.com/ProdNote: Fixed custom domains can be used in place of
execute-apiendpoints. When using custom domains, omit the/Prodsuffix (e.g.,wss://ws-dev.rgem.io).
> { "type": "ping" }
< { "type": "pong" }
Note that an app-level ping is distinct from a protocol/control-level ping. The former is sent by the virtual RGEM pad because there is no JavaScript API for sending control-level pings. Hardware devices only send control-level pings, which are handled by API Gateway.
> { "type": "hello", "gemId": "<gemId>" }
< { "type": "update", "gemState": "<base64-48-bytes>", "ts": "<base64-8-bytes>" }
The connection will be subscribed to <gemId> and will immediately receive its current state.
gemStateis a base64-encoded 48-byte payload representing 16 RGB triplets (16×3 bytes).tsis a base64-encoded 8-byte Big-Endian timestamp (milliseconds since epoch) used by clients to discard out-of-order updates.
> { "type": "toggle", "e": "keydown", "num": 0 }
"num": cell index (0–15)"e": event type —"keydown"cycles the cell through colors 1–8,"dblclick"turns the cell off
All connected websockets subscribed to <gemId> should immediately receive:
< { "type": "update", "gemState": "<base64-48-bytes>", "ts": "<base64-8-bytes>" }
All connected websockets subscribed to any <gemId> should receive the following every 9 minutes:
< { "type": "hb" }
While connected and subscribed to <gemId> via wscat, POST a gem state from a separate terminal:
# dev: api-dev.rgem.io
# stage: api-stage.rgem.io
# prod: api.rgem.io
curl -X POST \
https://<rest_api_host>/gem/<gemId> \
-H "Content-Type: application/json" \
--data-raw '[0,6,6,0,6,2,2,6,0,4,4,0,2,5,5,1]'Response:
{ "gemId": "<gemId>", "echo": <gemState> }All connected websockets subscribed to <gemId> should immediately receive:
< { "type": "update", "gemState": "<base64-48-bytes>", "ts": "<base64-8-bytes>" }
The HTTP response echoes the raw array, while WebSocket clients receive the encoded grid payload.
Contributions are welcome! Please see CONTRIBUTING.md for development setup, coding conventions, and pull request guidelines.
This project is licensed under the Apache License 2.0 — see the LICENSE file for details.