Interactive Poll
An interactive poll widget that lets the streamer create polls from the editor panel, lets chat viewers vote via the interactive page, and displays live results on the OBS layer overlay. Demonstrates all three surfaces working together with server-side persistence.
Project structure
poll-widget/
├── lumio.config.json
├── src/
│ ├── editor.tsx
│ ├── layer.tsx
│ ├── interactive.tsx
│ └── server/
│ └── functions.ts
└── package.json
lumio.config.json
{
"extensionId": "ext_placeholder",
"name": "Interactive Poll",
"version": "1.0.0",
"targets": ["layer", "editor", "interactive"],
"server": true,
"permissions": ["actions:invoke", "storage:read", "storage:write"]
}
src/server/functions.ts
import { action, query, v } from "@zaflun/lumio-sdk/server";
interface Poll {
question: string;
options: string[];
votes: Record<string, number>;
active: boolean;
}
// Create or replace the active poll
export const setPoll = action({
args: {
question: v.string(),
options: v.array(v.string()),
},
handler: async (ctx, args) => {
const poll: Poll = {
question: args.question,
options: args.options,
votes: Object.fromEntries(args.options.map((opt) => [opt, 0])),
active: true,
};
await ctx.db.set("global", "poll", poll);
await ctx.realtime.push("poll:updated", poll);
return poll;
},
});
// Register a viewer's vote
export const vote = action({
args: {
option: v.string(),
viewerId: v.string(),
},
handler: async (ctx, args) => {
const poll = await ctx.db.get<Poll>("global", "poll");
if (!poll || !poll.active) throw new Error("No active poll");
if (!poll.options.includes(args.option)) throw new Error("Invalid option");
// Prevent double-voting
const voteKey = `vote:${args.viewerId}`;
const hasVoted = await ctx.db.get<boolean>("global", voteKey);
if (hasVoted) throw new Error("Already voted");
poll.votes[args.option] = (poll.votes[args.option] ?? 0) + 1;
await ctx.db.set("global", "poll", poll);
await ctx.db.set("global", voteKey, true);
await ctx.realtime.push("poll:updated", poll);
return poll;
},
});
// Read the current poll state
export const getPoll = query({
args: {},
handler: async (ctx) => {
return ctx.db.get<Poll>("global", "poll");
},
});
// Close the poll
export const closePoll = action({
args: {},
handler: async (ctx) => {
const poll = await ctx.db.get<Poll>("global", "poll");
if (!poll) throw new Error("No poll");
poll.active = false;
await ctx.db.set("global", "poll", poll);
await ctx.realtime.push("poll:updated", poll);
return poll;
},
});
src/editor.tsx
import { Lumio, Box, Text, Input, Button, useLumioAction } from "@zaflun/lumio-sdk";
import { useState } from "react";
function PollEditor() {
const { invoke: setPoll, loading } = useLumioAction("setPoll");
const { invoke: closePoll } = useLumioAction("closePoll");
const [question, setQuestion] = useState("");
const [options, setOptions] = useState(["", ""]);
const addOption = () => setOptions([...options, ""]);
const updateOption = (i: number, value: string) => {
const next = [...options];
next[i] = value;
setOptions(next);
};
const handleStart = async () => {
const validOptions = options.filter((o) => o.trim().length > 0);
if (!question.trim() || validOptions.length < 2) return;
await setPoll({ question: question.trim(), options: validOptions });
setQuestion("");
setOptions(["", ""]);
};
return (
<Box style={{ padding: 20, display: "flex", flexDirection: "column", gap: 16 }}>
<Text content="Create Poll" variant="heading" />
<Input
label="Question"
value={question}
onChange={setQuestion}
placeholder="What should I play next?"
/>
{options.map((opt, i) => (
<Input
key={i}
label={`Option ${i + 1}`}
value={opt}
onChange={(v) => updateOption(i, v)}
placeholder={`Option ${i + 1}`}
/>
))}
<Button label="Add Option" onClick={addOption} variant="secondary" />
<Button label="Start Poll" onClick={handleStart} loading={loading} />
<Button label="Close Poll" onClick={() => closePoll({})} variant="danger" />
</Box>
);
}
Lumio.render(<PollEditor />, { target: "editor" });
src/interactive.tsx
The interactive page is opened by viewers in their browser. It shows the poll and lets them vote.
import { Lumio, Box, Text, Button, useLumioAction, useQuery } from "@zaflun/lumio-sdk";
import { useState } from "react";
interface Poll {
question: string;
options: string[];
votes: Record<string, number>;
active: boolean;
}
function PollVoting() {
const { data: poll, loading } = useQuery<Poll>("getPoll", {});
const { invoke: vote, loading: voting } = useLumioAction("vote");
const [voted, setVoted] = useState(false);
const [selected, setSelected] = useState<string | null>(null);
if (loading) return <Text content="Loading poll..." variant="muted" />;
if (!poll || !poll.active) return <Text content="No active poll right now." variant="muted" />;
const totalVotes = Object.values(poll.votes).reduce((a, b) => a + b, 0);
const handleVote = async () => {
if (!selected) return;
const viewerId = crypto.randomUUID(); // In production, use the identity from useLumioIdentity()
await vote({ option: selected, viewerId });
setVoted(true);
};
return (
<Box style={{ padding: 24, maxWidth: 480, margin: "0 auto" }}>
<Text content={poll.question} variant="heading" />
<Box style={{ display: "flex", flexDirection: "column", gap: 12, marginTop: 16 }}>
{poll.options.map((opt) => {
const count = poll.votes[opt] ?? 0;
const pct = totalVotes > 0 ? Math.round((count / totalVotes) * 100) : 0;
return (
<Box
key={opt}
onClick={() => !voted && setSelected(opt)}
style={{
border: `2px solid ${selected === opt ? "#6366f1" : "#374151"}`,
borderRadius: 8,
padding: "12px 16px",
cursor: voted ? "default" : "pointer",
position: "relative",
overflow: "hidden",
}}
>
{voted && (
<Box
style={{
position: "absolute",
inset: 0,
background: "rgba(99,102,241,0.15)",
width: `${pct}%`,
}}
/>
)}
<Box style={{ display: "flex", justifyContent: "space-between" }}>
<Text content={opt} variant="default" />
{voted && <Text content={`${pct}%`} variant="muted" />}
</Box>
</Box>
);
})}
</Box>
{!voted && (
<Button
label="Vote"
onClick={handleVote}
loading={voting}
style={{ marginTop: 16, width: "100%" }}
/>
)}
{voted && <Text content="Thanks for voting!" variant="muted" style={{ marginTop: 12 }} />}
</Box>
);
}
Lumio.render(<PollVoting />, { target: "interactive" });
src/layer.tsx
The layer shows live results on the overlay, updating in real time via WebSocket.
import { Lumio, Box, Text, useLumioAction } from "@zaflun/lumio-sdk";
import { useState, useEffect } from "react";
interface Poll {
question: string;
options: string[];
votes: Record<string, number>;
active: boolean;
}
function PollOverlay() {
const { invoke: getPoll } = useLumioAction("getPoll");
const [poll, setPoll] = useState<Poll | null>(null);
useEffect(() => {
getPoll({}).then((p) => setPoll(p as Poll | null));
// Real-time updates via WebSocket
const handler = (event: MessageEvent) => {
if (event.type === "poll:updated") {
setPoll(event.data as Poll);
}
};
window.addEventListener("lumio:realtime", handler);
return () => window.removeEventListener("lumio:realtime", handler);
}, [getPoll]);
if (!poll || !poll.active) return null;
const totalVotes = Object.values(poll.votes).reduce((a, b) => a + b, 0);
return (
<Box
style={{
position: "absolute",
top: 20,
left: 20,
background: "rgba(0,0,0,0.8)",
borderRadius: 10,
padding: "16px 20px",
minWidth: 280,
}}
>
<Text content={poll.question} variant="heading" style={{ marginBottom: 12 }} />
{poll.options.map((opt) => {
const count = poll.votes[opt] ?? 0;
const pct = totalVotes > 0 ? Math.round((count / totalVotes) * 100) : 0;
return (
<Box key={opt} style={{ marginBottom: 8 }}>
<Box style={{ display: "flex", justifyContent: "space-between", marginBottom: 3 }}>
<Text content={opt} variant="default" style={{ fontSize: 13 }} />
<Text content={`${pct}%`} variant="muted" style={{ fontSize: 13 }} />
</Box>
<Box style={{ height: 6, background: "#1f2937", borderRadius: 3 }}>
<Box
style={{
height: 6,
width: `${pct}%`,
background: "#6366f1",
borderRadius: 3,
transition: "width 0.4s ease",
}}
/>
</Box>
</Box>
);
})}
<Text content={`${totalVotes} votes`} variant="muted" style={{ fontSize: 11, marginTop: 8 }} />
</Box>
);
}
Lumio.render(<PollOverlay />, { target: "layer" });
Key concepts demonstrated
- Three surfaces — editor creates the poll, interactive page handles viewer voting, layer shows live results
ctx.realtime.push()— pushes poll updates to all connected surfaces via WebSocketctx.db.set/get— persists poll state in global scoped storage across invocationsuseQuery— fetches initial poll state declaratively- Double-vote prevention — stores a per-viewer flag in global storage