エンジニアが新婦のために結婚式にITで全力で貢献しようとした話【連載第9回】全員が得をするゲームの仕掛け
「チップを払ってメッセージを送る」が機能しなかった理由と、「送ると500チップもらえる」に反転させたUX改善の全記録。3カテゴリ制、いいね報酬、新郎新婦からの返信機能まで、メッセージを中心にした体験設計を解説。
最終更新:
全員が得をするゲームの仕掛け
〜「やらない理由」を消すUX設計のイテレーション〜
連載第9回。今回は、祝福メッセージシステムのUXを大幅に改善した話です。
「チップを消費してメッセージを送る」設計がテストプレイで見事に失敗し、「送るとチップがもらえる」に反転させ、さらに「いいね」「返信」で双方向コミュニケーションへ発展するまでの記録です。
😰 旧仕様の失敗:「払ってまで送りたくない」問題
元々の祝福メッセージは、チップを寄付して新郎新婦に送る仕組みでした。
旧: ゲストが自分のチップを消費 → 新郎新婦に「寄付」
発想としては悪くなかったのです。「お祝いの気持ちをチップで表現する」という世界観。
しかし、テストプレイで気づいた現実は**「チップを減らしてまでメッセージを送りたくない」**という心理的ハードルでした。
特に二次会の後半はランキング争いが白熱している時間帯。「500チップ寄付してメッセージを送る」より「500チップを温存してランキングを守る」を選ぶゲストが大半だったのです。
ゲーミフィケーションを入れたことで、逆にメッセージ送信を阻害してしまった。 これは設計者として反省すべきポイントでした。
🔄 発想の逆転:「送ればもらえる」
新: ゲストがメッセージを送る → 500チップ獲得!
損失回避(Loss Aversion)を報酬に反転させました。
この変更による効果は3つ:
- メッセージを送るインセンティブが生まれる — チップがもらえるなら送りたい
- ランキングを上げる手段としても機能する — メッセージ送信がゲーム戦略の一部に
- 新郎新婦に届くメッセージの数が増える — 本来の目的が達成される
ゲスト・新郎新婦の双方にとってWin-Winな設計です。
報酬額やクールダウンはハードコードせず、system_config テーブルで管理しています。
// system_config から報酬設定を取得
const blessingConfig = await getSystemConfigValue<BlessingConfig>(
"blessing_rewards",
{ reward_amount: 500, cooldown_minutes: 5 }
);
const REWARD_AMOUNT = blessingConfig.reward_amount;
デフォルトは500チップ / 5分クールダウンですが、当日の盛り上がりを見て管理画面から調整できます。リデプロイ不要で即時反映──これは第5回で解説した system_config パターンの実践です。
📋 3カテゴリ制の設計
なぜカテゴリを分けたのか
全員が同じ「お祝いメッセージ」だけだと、内容が似通ってしまいます。「ご結婚おめでとうございます」が40通届いても、読むほうも書くほうも面白くない。
そこで3つのカテゴリを用意しました。
| カテゴリ | Emoji | 色 | 意図 |
|---|---|---|---|
| お祝い | 💐 | ピンク | 定番のお祝いメッセージ |
| 質問 | ❓ | ブルー | 「新居は?」「ハネムーンは?」など |
| アドバイス | 💡 | アンバー | 夫婦円満の秘訣など |
**「質問」と「アドバイス」**を入れたのが重要なポイントです。「おめでとう」以外に何を書けばいいか分からない人にとって、「質問していいんだ」「アドバイスを送っていいんだ」という選択肢が心理的ハードルを下げます。
回数制限とチップインフレ防止
各カテゴリ: 最大2回
合計上限: 最大6回(3カテゴリ × 2回)
1回あたり: +500チップ
最大獲得: 3,000チップ
カテゴリごとに5分のクールダウンがあるため、連続送信はできません。回数制限を設けた理由はスパム防止とチップインフレ防止です。6回 × 500チップ = 3,000チップは、ゲーム全体のバランスを崩さない範囲に設計しています。
DB設計
-- blessingsテーブルにcategory列を追加
ALTER TABLE blessings
ADD COLUMN category TEXT DEFAULT 'celebration'
CHECK (category IN ('celebration', 'question', 'advice'));
-- カテゴリ別の送信数を効率的に取得するインデックス
CREATE INDEX idx_blessings_sender_category
ON blessings(sender_id, category);
カテゴリ設定は system_config テーブルのJSONBで管理し、管理画面から動的に変更可能にしています。
INSERT INTO system_config (key, value, description)
VALUES ('blessing_categories', '[
{"value":"celebration","label":"お祝い","emoji":"💐","color":"pink","maxPerUser":2},
{"value":"question","label":"質問","emoji":"❓","color":"blue","maxPerUser":2},
{"value":"advice","label":"アドバイス","emoji":"💡","color":"amber","maxPerUser":2}
]'::jsonb, 'メッセージカテゴリ設定');
🛡️ サーバー側バリデーション
クライアント側のUIで残回数を表示していますが、サーバー側でも必ずバリデーションしています。
// カテゴリ別の上限チェック
const { count: categoryCount } = await supabase
.from("blessings")
.select("*", { count: "exact", head: true })
.eq("sender_id", senderId)
.eq("category", category);
const categoryOption = catConfig.find(
(c: { value: string }) => c.value === category
);
if ((categoryCount ?? 0) >= (categoryOption?.maxPerUser ?? 2)) {
return { success: false, message: "このカテゴリの送信上限に達しました" };
}
// 合計の上限チェック(全カテゴリ合わせて6回まで)
const { count: totalCount } = await supabase
.from("blessings")
.select("*", { count: "exact", head: true })
.eq("sender_id", senderId);
const totalMax = catConfig.reduce(
(sum: number, c: { maxPerUser: number }) => sum + (c.maxPerUser ?? 2), 0
);
if ((totalCount ?? 0) >= totalMax) {
return { success: false, message: "メッセージの送信上限に達しました" };
}
フロントの制限はあくまでUXのため。不正防止はServer Actionで担保する原則を徹底しています。
❤️ いいね機能:メッセージが双方向になる
送りっぱなしは寂しい
メッセージを送った後、ゲストの画面は「送信完了」で止まります。これでは送りっぱなしです。
「自分のメッセージが新郎新婦に届いたのか」「読んでもらえたのか」がわからない。せっかく書いたメッセージが闇に消えたように感じるのは、体験としてもったいない。
いいねで「届いた!」を伝える
新郎新婦が管理画面からメッセージに「いいね」すると、送り手にもチップ報酬が入る仕組みにしました。
// いいね時のチップ報酬(system_config から取得)
const likeConfig = await getSystemConfigValue<LikeConfig>(
"like_rewards",
{ sender_reward: 200, liker_reward: 100 }
);
// 送り手にいいね報酬(メッセージを書いた人に+200チップ)
await supabase.from("transactions").insert({
user_id: blessing.sender_id,
amount: likeConfig.sender_reward,
game_type: "blessing_like_reward",
source_ref: `like:${blessingId}:sender`,
description: "いいね報酬(メッセージ送信者)",
});
// いいねした人にも報酬(新郎新婦に+100チップ)
await supabase.from("transactions").insert({
user_id: likerId,
amount: likeConfig.liker_reward,
game_type: "blessing_like_reward",
source_ref: `like:${blessingId}:liker`,
description: "いいね報酬",
});
source_ref で冪等性を保証しているため、同じメッセージに2回いいねしてもチップは1回分だけ。第10回で詳しく解説する多層防御のパターンがここでも活きています。
なぜ双方に報酬なのか
- 送り手: 「いいねされた!読んでくれたんだ!」→ もう1通送ろうかな、という動機
- いいねした側(新郎新婦): いいねするだけでチップが増える → 全メッセージに目を通す動機
「読んでもらえた」という体験が、次のメッセージを生む。 この循環がメッセージの質と量の両方を高めます。
💌 返信機能:新郎新婦からの言葉が残る
当日追加した機能
実は返信機能は、結婚式の2日前に急遽追加しました。テスト中に新婦から「メッセージに返信できたら嬉しい」というリクエストがあったのです。
-- 急遽追加した返信テーブル
CREATE TABLE blessing_replies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
blessing_id UUID NOT NULL REFERENCES blessings(id) ON DELETE CASCADE,
reply_text TEXT NOT NULL,
replied_by UUID NOT NULL REFERENCES profiles(id),
created_at TIMESTAMPTZ DEFAULT now()
);
管理画面から新郎新婦がメッセージに返信すると、ゲストのサンキューページ(第11回で解説)に返信が表示されます。
イベント後、ゲストがサンキューページを開いたとき、自分のメッセージに新郎新婦からの返信がある。 これは「いいね」以上の感動体験です。
返信にも500チップの報酬をつけていますが、これはインセンティブというよりトランザクション記録のためです。全てのチップ移動を transactions テーブルで追跡する設計原則を維持しています。
📺 メッセージ一覧ページ:大画面投影
新郎新婦に届いたメッセージを会場スクリーンに一覧表示するページ /blessing/list を追加しました。
┌─────────────────────────────────────────────────┐
│ Messages │
│ 新郎新婦へのメッセージ │
│ │
│ 12 5 4 3 │
│ メッセージ 💐 お祝い ❓ 質問 💡 アドバイス │
├─────────────────────────────────────────────────┤
│ [すべて 12] [💐 お祝い 5] [❓ 質問 4] [💡 3] │
├─────────────────────────────────────────────────┤
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │💐 お祝い│ │❓ 質問 │ │💡アドバイス│ │
│ │山田太郎 │ │鈴木花子 │ │田中一郎 │ │
│ │おめでと │ │新居は? │ │ありがとう│ │
│ │う! │ │ │ │を忘れず │ │
│ └─────────┘ └─────────┘ └─────────┘ │
└─────────────────────────────────────────────────┘
カードの左ボーダーをカテゴリの色で色分けし、一目でどのカテゴリか分かるようにしています。
📸 フォトギャラリー:event_modeテーマ切替
ゲストがアップロードした写真を大画面に投影するページ /gallery も追加しました。
特徴的なのは、システムの event_mode 設定に連動してデザインテーマが自動で切り替わる点です。
| 要素 | reception(披露宴) | casino(二次会) |
|---|---|---|
| 背景 | 白〜ローズ | 黒〜グレー |
| アクセント | ローズ/ピンク | ゴールド/アンバー |
| タイトル | ”Wedding Photo Gallery" | "Photo Gallery” |
管理画面から event_mode を変更するだけで、ギャラリーの見た目が披露宴用と二次会用に切り替わります。テーマオブジェクトで全スタイルを管理しているため、モード切替が1行で完結します。
✂️ アニメーション選択UIの削除
旧仕様ではメッセージ送信時にエフェクト(ハート/紙吹雪/花火)を選択できましたが、UIをシンプルにするために削除しました。
変更前: カテゴリ選択 + メッセージ入力 + アニメーション選択(3ステップ) 変更後: カテゴリ選択 + メッセージ入力(2ステップ)
「減らす勇気」もUX設計の一部です。 選択肢が増えるほど意思決定コストが上がり、結果的に「面倒だからやめよう」につながります。
UXイテレーションのパターン
今回の改修で得た教訓を、一般的なパターンとしてまとめます。
パターン1:損失回避の反転
NG: ユーザーが何かを「失う」ことで行動を促す
OK: ユーザーが何かを「得る」ことで行動を促す
ゲーミフィケーションで「コストとしての通貨消費」を設計すると、通貨の価値が高まるほど行動を抑制する逆効果が生まれます。報酬型に反転させることで、行動のインセンティブと通貨価値の両方を維持できます。
パターン2:カテゴリによる選択肢の制約
NG: 自由入力のみ(「何を書けばいいか分からない」)
OK: カテゴリで方向性を示す + 自由入力
完全な自由は、しばしば行動を阻害します。適度な制約(カテゴリ)を設けることで、ユーザーは「何をすればいいか」が明確になり、行動につながります。
パターン3:双方向報酬で循環を作る
送信者: メッセージを送る → +500チップ
受取側: いいねする → +100チップ(送信者にも+200チップ)
片方向の報酬ではなく、双方に報酬を設けることで「送る→読む→いいねする→また送る」の循環を生みます。source_refによる冪等性で二重付与を防止。
💡 設計改善で学んだこと
今回の改修のポイントは**「心理的ハードルを下げる」**ことでした。
- チップを消費する → チップがもらえる(損失回避を報酬に反転)
- 「何を書けば?」 → カテゴリとプレースホルダーで誘導(選択肢を狭める)
- 送りっぱなし → いいね+返信で反応が返る(双方向コミュニケーション)
- 残回数バッジ → あと何回送れるか一目瞭然(行動を促す)
ゲーミフィケーションの設計では、ユーザーに「やらない理由」を与えないことが重要です。報酬があり、ガイドがあり、フィードバックがある。この3つが揃えば、ゲストは自然にメッセージを送ってくれます。
🔜 次回予告
ここまで様々な機能を実装してきましたが、「ゲストが不正をしたらどうする?」という問題にまだ触れていません。
次回は、クライマックスルール、冪等性保証、レート制限、RLS設計 — ゲームバランスを守るための多層防御について解説します。「仲間内でチップを集約して1位を取る」不正をどう防ぐか。
次回、第10回「ゲームを壊さないための多層防御設計」をお楽しみに。
この記事が面白いと思ったら、ぜひシェアをお願いします!
あなたのシェアが、同じような「面白いことやりたいエンジニア」に届くかもしれません。
tinou
情報処理安全確保支援士とPMの資格を使ってITコンサルタントとして働く傍ら、自宅で自動化とセキュリティを研究しているエンジニア