Voting booth
A shared vote with choices, per-player decisions, and editor configuration.
What it demonstrates: Actor context · configuration · permission checks.
Run this example
All learning examples appear in a compatible developer world. To export a standalone project, an operator with source access runs this from the Scape repository (choose a new destination under an existing directory):
sh
yarn sdk:starter /path/to/my-voting-booth --template voting-booth1
In the exported project:
sh
yarn install
yarn build
yarn dev --origin https://your-scape-host1
2
3
2
3
Follow connection and approval. The exported dev script invokes scape gizmo dev. The kit includes three private archives for the SDK, CLI and MCP server; keep them and the Yarn resolutions together. Third-party dependencies still need installation. See the CLI reference for commands; no public Scape registry install is available yet.
Try changing it
Change the prompt and choices through the editor action. Keep identity references in validated state, and never use display names as authority.
Source
This code is included directly from the working example when the site builds.
src/definition.ts
ts
/** voting-booth: SDK definition and authored behavior. Scape supplies the host services. */
import {
defineObject,
exactKeys,
ObjectActionError,
actorId,
record,
requirePayload,
} from '@scape/sdk';
export interface VotingState {
question: string;
choices: string[];
ballots: { voter: string; choice: number }[];
round: number;
open: boolean;
}
const text = (value: unknown, max: number): value is string =>
typeof value === 'string' &&
value === value.trim() &&
value.length > 0 &&
value.length <= max &&
!/[\u0000-\u001f\u007f]/.test(value);
const valid = (s: unknown): s is VotingState =>
record(s) &&
exactKeys(s, ['question', 'choices', 'ballots', 'round', 'open']) &&
text(s.question, 120) &&
Array.isArray(s.choices) &&
s.choices.length >= 2 &&
s.choices.length <= 4 &&
s.choices.every((c) => text(c, 60)) &&
new Set(s.choices).size === s.choices.length &&
Number.isSafeInteger(s.round) &&
Number(s.round) >= 0 &&
typeof s.open === 'boolean' &&
Array.isArray(s.ballots) &&
s.ballots.length <= 256 &&
s.ballots.every(
(b) =>
record(b) &&
exactKeys(b, ['voter', 'choice']) &&
actorId(b.voter) &&
Number.isInteger(b.choice) &&
Number(b.choice) >= 0 &&
Number(b.choice) < (s.choices as unknown[]).length,
) &&
new Set(s.ballots.map((b) => b.voter)).size === s.ballots.length;
function round(state: VotingState, payload: Record<string, unknown>) {
if (payload.round !== state.round)
throw new ObjectActionError(409, 'This poll changed. Open it again.');
}
export const votingBooth = defineObject<VotingState>({
type: 'scape.voting-booth',
version: 1,
emoji: '🗳️',
label: 'Voting booth',
hint: 'tap to vote together',
initial: () => ({
question: 'What should we do next?',
choices: ['Explore', 'Play a game'],
ballots: [],
round: 0,
open: true,
}),
valid,
actions: {
vote: {
permission: 'participant',
run: (state, payload, context) => {
requirePayload(payload, ['round', 'choice']);
round(state, payload);
if (!state.open) throw new ObjectActionError(409, 'Voting is closed');
if (
!Number.isInteger(payload.choice) ||
Number(payload.choice) < 0 ||
Number(payload.choice) >= state.choices.length
)
throw new ObjectActionError(400, 'Choose an answer');
const ballots = state.ballots.filter((b) => b.voter !== context.actorId);
if (ballots.length >= 256) throw new ObjectActionError(409, 'This poll is full');
return {
...state,
ballots: [...ballots, { voter: context.actorId, choice: Number(payload.choice) }],
};
},
},
configure: {
permission: 'editor',
run: (state, payload) => {
requirePayload(payload, ['round', 'question', 'choice0', 'choice1', 'choice2', 'choice3']);
round(state, payload);
if (state.ballots.length)
throw new ObjectActionError(409, 'Reset the poll before changing its question');
const question = typeof payload.question === 'string' ? payload.question.trim() : '';
const choices = [payload.choice0, payload.choice1, payload.choice2, payload.choice3]
.map((c) => (typeof c === 'string' ? c.trim() : ''))
.filter(Boolean);
const next = {
...state,
question,
choices,
round: state.round + 1,
};
if (!valid(next))
throw new ObjectActionError(400, 'Enter a question and two to four different answers');
return next;
},
},
toggle: {
permission: 'editor',
run: (state, payload) => {
requirePayload(payload, ['round']);
round(state, payload);
return {
...state,
open: !state.open,
round: state.round + 1,
};
},
},
reset: {
permission: 'editor',
run: (state, payload) => {
requirePayload(payload, ['round']);
round(state, payload);
return {
...state,
ballots: [],
round: state.round + 1,
open: true,
};
},
},
},
view: (state, viewer) => ({
title: state.question,
description: `${state.ballots.length} vote${state.ballots.length === 1 ? '' : 's'} · ${state.open ? 'Voting open' : 'Voting closed'}. One vote per device. You can change your vote. Votes are not anonymous.`,
fields:
viewer.canEdit && !state.ballots.length
? [
{
id: 'question',
label: 'Question',
value: state.question,
maxLength: 120,
},
...Array.from({ length: 4 }, (_, i) => ({
id: `choice${i}`,
label: `Answer ${i + 1}${i > 1 ? ' (optional)' : ''}`,
value: state.choices[i] || '',
maxLength: 60,
})),
]
: [],
controls: [
...state.choices.map((choice, i) => ({
id: `vote-${i}`,
label: `${choice} · ${state.ballots.filter((b) => b.choice === i).length}`,
action: { name: 'vote', payload: { choice: i, round: state.round } },
disabled: !state.open,
pressed: state.ballots.some((b) => b.voter === viewer.actorId && b.choice === i),
})),
...(viewer.canEdit
? [
...(!state.ballots.length
? [
{
id: 'configure',
label: 'Save question',
fields: ['question', 'choice0', 'choice1', 'choice2', 'choice3'],
action: { name: 'configure', payload: { round: state.round } },
},
]
: []),
{
id: 'toggle',
label: state.open ? 'Close voting' : 'Reopen voting',
action: { name: 'toggle', payload: { round: state.round } },
},
{
id: 'reset',
label: 'Reset poll',
confirm: 'Clear all votes?',
action: { name: 'reset', payload: { round: state.round } },
},
]
: []),
],
}),
});
export default votingBooth;1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
src/project.ts
ts
/** Project entry: exports voting-booth to Scape's SDK loader. */
import { defineProject } from '@scape/sdk';
import { votingBooth } from './definition.js';
export default defineProject({ objects: [votingBooth] });1
2
3
4
5
2
3
4
5
