バージョン: 2.0 最終更新: 2026-01-27
プレイヤーID管理:
function getPlayerId(): string {
if (typeof window === 'undefined') return '';
let id = localStorage.getItem('nonaga_player_id');
if (!id) {
id = crypto.randomUUID(); // UUID v4
localStorage.setItem('nonaga_player_id', id);
}
return id;
}特徴:
- アカウント登録不要(匿名プレイ)
- ブラウザごとに一意のUUID
- localStorageクリアで新しいIDを取得
セキュリティリスク:
- ❌ なりすまし可能(UUID知られると)
- ❌ 複数デバイスで同じプレイヤーとして扱えない
軽減策:
- ゲームIDとプレイヤーIDの組み合わせで検証
- Lambda側でホスト/ゲストの固定チェック
- ゲームセッションは24時間で自動削除
AppSync認証設定:
authorizationConfig: {
defaultAuthorization: {
authorizationType: appsync.AuthorizationType.API_KEY,
apiKeyConfig: {
expires: cdk.Expiration.after(cdk.Duration.days(365)),
},
},
},API Keyの管理:
- 保存場所: 環境変数(
APPSYNC_API_KEY) - アクセス: Next.js API Routesからのみ(サーバーサイド)
- クライアント: ブラウザに公開されない
リクエストフロー:
ブラウザ
→ Next.js API Route(公開エンドポイント)
→ AppSync(API Key認証、サーバー内部のみ)
→ DynamoDB / Lambda
セキュリティ上の利点:
- API Keyがブラウザに露出しない
- Next.js API Routesでレート制限・バリデーション可能
Lambda側のバリデーション:
// 1. ゲーム状態取得
const game = await getGameFromDynamoDB(gameId);
// 2. ステータス確認
if (game.status !== 'PLAYING') {
throw new Error('Game is not active');
}
// 3. フェーズ確認
if (game.phase !== 'move_token') {
throw new Error('Wrong phase');
}
// 4. プレイヤー認証
const playerColor = getPlayerColor(game, playerId);
if (!playerColor) {
throw new Error('Player not in game');
}
// 5. ターン確認
if (game.turn !== playerColor) {
throw new Error('Not your turn');
}
// 6. コマ所有権確認
const piece = game.pieces.find(p => p.id === pieceId);
if (piece.player !== playerColor) {
throw new Error('Not your piece');
}
// 7. 移動可能性確認
const isValid = isValidPieceMove(piece, toQ, toR, game.tiles, game.pieces);
if (!isValid) {
throw new Error('Invalid move');
}二重検証:
- クライアント: UX最適化(即座にエラー表示)
- サーバー: セキュリティ(不正操作を確実にブロック)
Reactの自動エスケープ:
// 安全(自動エスケープ)
<div>{userInput}</div>
// 危険(使用しない)
<div dangerouslySetInnerHTML={{ __html: userInput }} />ユーザー入力の扱い:
- ルームコード: 数字のみ許可(正規表現で検証)
- プレイヤーID: UUID v4(固定フォーマット)
- ゲームID: UUID v4(固定フォーマット)
SameSite Cookie設定:
// Next.js の default 設定
cookies().set('name', 'value', {
sameSite: 'lax', // CSRF攻撃を防ぐ
});HTTPS必須:
- 本番環境はAmplify Hosting(HTTPS強制)
- Cookie の Secure フラグ有効
AppSync/Lambda:
- AWSのDDoS Protection(AWS Shield Standard)
- Lambda同時実行数制限(デフォルト1000)
Amplify Hosting:
- CloudFront CDN(DDoS Protection標準装備)
将来的な対策:
- WAF(Web Application Firewall)設定
- レート制限(API Gateway Throttling)
ブレークポイント:
/* スマホ */
@media (max-width: 640px) {
/* HEX_SIZE: 28px */
}
/* タブレット */
@media (min-width: 641px) and (max-width: 1024px) {
/* HEX_SIZE: 34px */
}
/* デスクトップ */
@media (min-width: 1025px) {
/* HEX_SIZE: 38px */
}SVGのViewBox自動調整:
const { minX, minY, maxX, maxY } = calculateViewBounds(tiles);
const padding = HEX_SIZE * 2;
<svg
viewBox={`${minX - padding} ${minY - padding} ${maxX - minX + padding * 2} ${maxY - minY + padding * 2}`}
width="100%"
height="100%"
>コマ移動アニメーション:
.piece {
transition: transform 500ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
}タイル移動アニメーション:
.tile {
transition: transform 500ms cubic-bezier(0.25, 0.46, 0.45, 0.94);
}勝利エフェクト:
@keyframes victory-glow {
0%, 100% {
filter: drop-shadow(0 0 8px gold);
}
50% {
filter: drop-shadow(0 0 16px gold);
}
}
.victory-piece {
animation: victory-glow 1s ease-in-out infinite;
}ホバーエフェクト:
.tile:hover, .piece:hover {
filter: brightness(1.2);
cursor: pointer;
}
.valid-destination {
fill: rgba(255, 215, 0, 0.3);
stroke: gold;
stroke-width: 2;
animation: pulse 1s ease-in-out infinite;
}言語管理:
type Lang = 'ja' | 'en';
const I18N = {
ja: { /* 日本語文字列 */ },
en: { /* 英語文字列 */ },
};
// localStorage + document.documentElement.lang で永続化
const lang = localStorage.getItem('nonaga_lang') || document.documentElement.lang || 'ja';言語切り替えUI:
<button onClick={toggleLang}>
{lang === 'ja' ? 'EN' : 'JP'}
</button>対応文字列:
- ゲームタイトル
- ボタンラベル(「ゲーム作成」「参加」など)
- ステータスメッセージ(「あなたのターンです」など)
- エラーメッセージ
- 勝利メッセージ
- アバウトページ
キーボード操作:
- タブキーでフォーカス移動
- Enterキーで選択
スクリーンリーダー対応:
<svg aria-label="ゲームボード">
<circle aria-label={`${color}のコマ、位置 ${q},${r}`} />
<polygon aria-label={`タイル、位置 ${q},${r}`} />
</svg>色覚異常対応:
- 赤:
#e74c3c(明るい赤) - 青:
#3498db(明るい青) - 十分なコントラスト比(WCAG AA準拠)
将来の改善:
- ハイコントラストモード
- フォントサイズ調整
- モーション削減オプション
| 指標 | 目標値 |
|---|---|
| First Contentful Paint (FCP) | < 1.5秒 |
| Largest Contentful Paint (LCP) | < 2.5秒 |
| Time to Interactive (TTI) | < 3.0秒 |
| Cumulative Layout Shift (CLS) | < 0.1 |
Standalone Output:
// next.config.js
module.exports = {
output: 'standalone', // 最小限のランタイム
};自動コード分割:
- ページごとに自動分割(App Router)
- Dynamic Import未使用(ページ数が少ない)
読み取り性能:
- GetItem: ~10ms
- Query (GSI): ~20ms
書き込み性能:
- PutItem: ~15ms
- UpdateItem: ~15ms
スループット:
- PAY_PER_REQUEST: 自動スケーリング
- バーストキャパシティ: 最大40,000 RCU/WCU
Cold Start:
- メモリ256MB: ~500ms
- メモリ512MB: ~300ms(現在256MBで運用中)
Warm実行:
- 平均: ~50ms
- P99: ~200ms
最適化:
- esbuild バンドル(サイズ削減)
- 環境変数キャッシュ
- DynamoDB DocumentClient再利用
リクエスト量:
- 1ユーザー: 1秒に1回
- 1000ユーザー: 1000 QPS
- コスト: ~$5/月(Lambda + DynamoDB + AppSync)
最適化:
updatedAt比較でReact再レンダリング削減- アニメーション中はポーリングスキップ
- ABANDONED時は停止
API エラーハンドリング:
try {
const res = await fetch(`/api/game/${gameId}/move`, {
method: 'POST',
body: JSON.stringify(payload),
});
if (!res.ok) {
const errorData = await res.json();
throw new Error(errorData.error || 'Unknown error');
}
const game = await res.json();
setGame(game);
} catch (err) {
if (err instanceof Error) {
setError(err.message);
} else {
setError('Unknown error');
}
// ロールバック
setGame(previousGame);
}ネットワークエラー:
async function fetchWithRetry(url, options, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
const res = await fetch(url, options);
return res;
} catch (err) {
if (i === maxRetries - 1) throw err;
await new Promise(resolve => setTimeout(resolve, 500 * (i + 1)));
}
}
}エラー表示:
{error && (
<div className="error-banner">
<p>{error}</p>
<button onClick={() => setError(null)}>閉じる</button>
</div>
)}Lambda エラーレスポンス:
try {
// ゲームロジック実行
const updatedGame = await processMove(input);
return updatedGame;
} catch (err) {
console.error('Move error:', err);
if (err.message === 'Not your turn') {
throw new Error('Not your turn'); // 400 Bad Request
}
if (err.message === 'Invalid move') {
throw new Error('Invalid move'); // 400 Bad Request
}
throw new Error('Internal server error'); // 500
}DynamoDB エラー:
try {
await docClient.send(new UpdateCommand({ /* ... */ }));
} catch (err) {
if (err.name === 'ConditionalCheckFailedException') {
throw new Error('Game state changed');
}
if (err.name === 'ResourceNotFoundException') {
throw new Error('Game not found');
}
throw err;
}| エラー | 表示メッセージ(日本語) | 表示メッセージ(英語) |
|---|---|---|
| ゲーム未発見 | ゲームが見つかりません | Game not found |
| 無効な移動 | 無効な移動です | Invalid move |
| 他人のターン | あなたのターンではありません | Not your turn |
| 他人のコマ | あなたのコマではありません | Not your piece |
| フェーズエラー | この操作はできません | Cannot perform this action now |
| 連結性エラー | 盤面が分断されます | Board must remain connected |
| ネットワークエラー | 接続に失敗しました | Connection failed |
X-Rayトレーシング:
- 有効化済み(
xrayEnabled: true) - リクエストごとのトレース
- レイテンシー・エラー分析
CloudWatch Logs:
logConfig: {
fieldLogLevel: appsync.FieldLogLevel.ERROR,
}ログ内容:
- エラーログのみ(コスト最適化)
- GraphQL リクエスト・レスポンス
- Resolver実行時間
CloudWatch Logs:
- 自動ロギング(
console.log,console.error) - ログ保持期間: 無期限(デフォルト)
カスタムメトリクス:
console.log('Move executed', {
gameId,
playerId,
pieceId,
duration: Date.now() - startTime,
});X-Rayトレーシング:
- AppSync経由で自動トレース
- DynamoDB呼び出しの可視化
CloudWatch メトリクス:
ConsumedReadCapacityUnitsConsumedWriteCapacityUnitsUserErrors(バリデーションエラー)SystemErrors
アラーム設定(推奨):
SystemErrors > 10→ 通知UserErrors > 100→ 調査
ビルドログ:
- Amplify Console で確認
- ビルド成功/失敗通知
アクセスログ:
- CloudFront アクセスログ(オプション)
- リクエスト数・エラー率
アプリケーションメトリクス:
- アクティブゲーム数(DynamoDB Count)
- 平均ゲーム時間
- ルームコード利用率
- エラー発生率
ユーザーメトリクス:
- DAU/MAU
- ゲーム完了率
- リマッチ率
インフラメトリクス:
- Lambda Cold Start頻度
- DynamoDB スロットリング
- AppSync レスポンスタイム
-
ポーリング遅延
- 最大1秒の状態同期遅延
- リアルタイム性が求められる場合は不適
-
プレイヤー認証の脆弱性
- UUIDベースの匿名認証
- なりすまし可能(UUID漏洩時)
-
DynamoDB TTL の遅延
- 削除タイミングは48時間以内(通常数時間)
- 厳密に24時間で削除されるわけではない
-
AppSync Subscription未使用
- スキーマ定義は存在するが実装なし
- 将来的にリアルタイム化する場合は移行が必要
-
Lambda Cold Start
- 初回リクエストで500ms程度の遅延
- Provisioned Concurrency未設定(コスト最適化優先)
-
観戦モード未実装
- プレイヤー以外は閲覧不可
-
リプレイ機能なし
- 過去のゲームを見返せない
-
ランキング機能なし
- 勝敗記録の保存なし
-
チャット機能なし
- プレイヤー間のコミュニケーション手段なし
-
ブラウザバック時の挙動
- ゲーム画面からバックすると状態が不整合になる可能性
- 対策: ポーリングで自動修復
-
複数タブで同時プレイ
- 同じプレイヤーIDで複数タブを開くと状態競合
- 対策: lastUpdateRefで最新状態に収束
-
スマホ横画面対応
- 縦画面推奨
- 横画面でレイアウト崩れの可能性
-
IE11非対応
- ES2020構文使用(Optional Chaining等)
- モダンブラウザのみサポート
想定規模:
- 同時接続ゲーム: ~1,000
- 同時ユーザー: ~2,000
限界点:
- Lambda同時実行数: 1,000(デフォルト上限)
- DynamoDB: 実質無制限(PAY_PER_REQUEST)
- Amplify Hosting: CloudFront CDNで大量アクセス対応可能
スケールアップ方法:
- Lambda同時実行数上限引き上げ(AWSサポート申請)
- DynamoDB Provisioned Capacityに切り替え(予測可能な負荷)
-
観戦モード
- プレイヤー以外がゲームを閲覧可能
- 実装:
spectatorIds配列追加
-
リマッチ通知
- 相手がリマッチリクエストを送ったことを通知
- 実装:
rematchRequestedByフィールド追加
-
ゲームタイマー
- ターン制限時間(例: 60秒)
- 実装:
turnStartedAtフィールド + Lambda定期実行
-
エラー改善
- より詳細なエラーメッセージ
- エラーログ収集・分析
-
ユーザーアカウント
- メールアドレスログイン
- 実装: Amazon Cognito統合
-
フレンド機能
- フレンドリスト
- オンライン状態表示
- 実装: DynamoDB追加テーブル
-
ランキング
- 勝率・勝利数ランキング
- 実装: DynamoDB GSI + 集計Lambda
-
リプレイ保存
- 過去のゲームを再生
- 実装: S3 に棋譜保存
-
チャット機能
- ゲーム内簡易チャット
- 実装: AppSync Subscription
-
カスタムルール
- タイル数・コマ数変更
- 勝利条件カスタマイズ
-
トーナメントモード
- 複数プレイヤーのトーナメント
- 実装: ステートマシン(Step Functions)
-
モバイルアプリ
- React Native版
- プッシュ通知対応
-
AI強化
- 機械学習ベースのAI
- 実装: SageMaker + Lambda
-
リアルタイム化
- WebSocketによる即座の状態同期
- 実装: AppSync Subscription本格導入
-
パフォーマンス最適化
- Lambda Provisioned Concurrency(ピーク時のみ)
- DynamoDB DAX(キャッシュ)
- Next.js ISR(Incremental Static Regeneration)
-
テスト強化
- Unitテスト(Jest)
- E2Eテスト(Playwright)
- 負荷テスト(Artillery)
-
監視強化
- カスタムダッシュボード(CloudWatch Dashboard)
- エラー通知(SNS + Email)
- アラート設定(CloudWatch Alarms)
-
コスト最適化
- Lambda メモリサイズ調整
- DynamoDB TTL による自動削除の徹底
- CloudFront キャッシュ最適化
| 用語 | 説明 |
|---|---|
| 軸座標系(Axial Coordinates) | 六角形グリッドを2次元座標(q, r)で表現する座標系 |
| スライド移動 | コマが直線方向に障害物にぶつかるまで進む移動方式 |
| 連結性チェック | タイル移動後に全タイルが連結しているかBFSで検証 |
| ルームコード | 6桁の数字によるマッチングコード |
| ポーリング | 定期的にサーバーに状態を問い合わせる方式 |
| 楽観的UI更新 | サーバー検証前に画面を更新し、後で修正する手法 |
| TTL(Time To Live) | レコードの有効期限、期限切れで自動削除 |
| GSI(Global Secondary Index) | DynamoDBの追加インデックス |
| PAY_PER_REQUEST | DynamoDBのオンデマンド課金モード |
| Cold Start | Lambda関数の初回実行時の初期化遅延 |
| Standalone Output | Next.jsの最小ランタイム出力モード |
技術ドキュメント:
六角座標系:
NONAGAプロジェクト:
- GitHub:
https://github.com/Yuuga2001/nonaga - 本番URL:
https://hexlide.riverapp.jp/