Human vs AI : Lets build a AI Multi-Agent Gaming Platform
A deep dive into agentic architecture: how Bedrock AgentCore, MCP, agent-to-agent collaboration, an orchestrator, memory, a live caption commentator, and a leaderboard fit together into one fully serverless platform.
A deep dive into agentic architecture: how Bedrock AgentCore, MCP, agent-to-agent collaboration, an orchestrator, memory, a live caption commentator, and a leaderboard fit together into one fully serverless platform.
Overview
Most “AI agent” demos stop at a single model behind a chat box. You type, it replies, the end. That’s fine for a tutorial, but it hides every problem that actually matters when you try to ship agentic software:
- What happens when one agent isn’t enough?
- How do agents do things instead of just talking?
- How do you keep the UI responsive when a model takes seconds to think?
- How do two agents collaborate without stepping on each other?
- How does one agent’s clever capability get reused by the next ten?
- How do you keep all of this cheap, secure, and observable?
I wanted a single project that forced me to answer all of those at once. A game platform was the perfect excuse, because games are:
- Turn-based — clean async boundaries.
- Rule-bound — clear, testable validation.
- Personality-rich — begging for commentary and adaptation.
So I built a platform where a human plays against specialist AI agents, every move is narrated by a live textual commentator, scores roll into a leaderboard, and the AI gradually learns how you play. Two games ship today:
- Tic-Tac-Toe — fast, perfect-information, the proving ground for the core loop.
- Tactical Arena — 8×8 squad combat with Tank, Striker, and Support units, where the AI must plan and coordinate three pieces against three enemies.
Everything runs on AWS, fully serverless:
- Agents hosted on Amazon Bedrock AgentCore Runtime, reasoning with Amazon Nova or Claude models.
- Six model profiles (easy/medium/hard × Amazon/Claude) literally choose which model the opponent thinks with — a frontend dropdown drives the Bedrock inference-profile call.
- An orchestrator routes turns; an agent-to-agent split powers hard mode.
- A shared commentary capability is exposed over MCP.
- Lightweight per-player memory and a leaderboard live in one DynamoDB table.
The games are almost beside the point. What’s interesting is how the AI layers fit together — and that’s what this post is about.
The mental model: games as a thin shell around agents
Before writing a line of code I made one decision that shaped everything:
The game is a thin deterministic shell; the intelligence is a swappable, asynchronous service.
A game contributes only three small things:
- Its rules.
- Its agent prompt.
- Its action tool.
Everything else is shared platform — transport, events, routing, commentary, memory, and the leaderboard. That single principle pays off again and again:
- Adding a game becomes a folder, not a rewrite.
- The AI can be slow without the UI being slow, because the two are separated by an event bus.
- Commentary becomes a reusable capability instead of code copied into each game.
- Difficulty becomes a dial, not a branch.
- Memory and scoring come for free to any new game on day one.
As you read the layers below, notice how nearly every choice traces back to this mental model: keep games thin, keep AI decoupled, make capabilities shareable.
The BIG picture
Four layers — presentation (React), transport (API Gateway WebSocket), game mechanics (Lambda + DynamoDB), and AI control (orchestrator, agents, commentary, coaching, memory) — stitched together by EventBridge.

The cardinal rule, visible in the shape of the diagram: a player’s connection is never blocked waiting on a model. A move is validated in milliseconds and the socket goes free; the agent’s reply and the commentary arrive asynchronously over the same WebSocket later.
It helps to read the diagram as two distinct journeys that start from the same click:
- The synchronous journey is short and boring on purpose: click → WebSocket → engine → DynamoDB → 200 OK. It finishes before the model has even warmed up, and it’s the only part the player waits on.
- The asynchronous journey is everything to the right of EventBridge: routing, model inference, agent-to-agent planning, tool execution, persistence, scoring, and commentary. The player waits on none of it — results stream back when ready.
Drawing a hard line between those two journeys is what makes a model-heavy product feel like a snappy app instead of a spinner.
EventBridge sits in the exact middle of the picture, not off to the side — and that’s intentional. It’s the contract between the two halves: the left half only knows how to emit events, the right half only knows how to react. Neither holds a reference to the other, which buys three properties almost for free:
- Independent scaling — commentary and gameplay scale separately.
- Independent failure — Polly can be down and the game plays on.
- Independent evolution — I can rewrite the entire AI side without touching the engine.
If you remember one thing from this section: the bus, not the agent, is the center of gravity.
A final way to read the picture — every box on the right is stateless and swappable, and all durable state collapses into the single DynamoDB node:
- The orchestrator, agents, action groups, and commentator hold nothing between turns — read the match, do one job, write back, forget.
- That statelessness is why it can be fully serverless and pay-per-use: no server to keep warm, no session pinned to a host, nothing to drain on deploy.
- The match in DynamoDB is the session; everything else is ephemeral compute wrapped around it.
We’ll take each layer in turn, then spend most of our time on the AI pieces — orchestrator, AgentCore, tools, A2A, MCP, memory, and the commentator — because that’s where the real design lives.
The presentation and transport layers
The frontend is a small React SPA, and deliberately so — it should be a dumb terminal for a smart backend. A single shared hook owns the WebSocket and handles four message types:
state— the board to render.leaderboard— top-N rows.commentary— a caption plus base64 audio (auto-decoded and played).error— rejected moves and the like.
No polling, no client-side rules, no scoring math — the client renders what the server pushes.
Transport is API Gateway WebSocket, chosen specifically because it’s duplex:
- The server can push to the client at any time — the non-negotiable requirement for an async AI backend.
- The human’s move and the AI’s eventual reply share one connection but are independent in time.
- A route-selection expression reads each message’s
actionand routes it:move→ Game Engine,leaderboard→ leaderboard reader.
Because the channel is push-capable, the AI can take 300ms or 3 seconds and the experience is identical from the socket’s perspective. This is the bedrock that makes everything above it feel instant.
The game-mechanics layer: rules belong in code
A recurring temptation with agents is to let the model own correctness. I deliberately don’t.
Rules live in Lambda; the model only contributes strategy.
The Game Engine:
- Loads or creates a match.
- Validates the move.
- Writes state to DynamoDB and flips
currentTurnto AI. - Returns immediately.
The state stays tiny on purpose:
- Tic-Tac-Toe is a 9-cell array of
1(human),-1(AI),0(empty) — compact and token-cheap so prompts stay small. - Tactical Arena stores unit positions and HP, with damage =
max(1, atk − def)and Manhattan-distance movement.
Why this split is deliberate:
- Security — a client can’t submit an illegal move (the engine is the referee), and the model can’t break the game (its proposals are validated).
- Clarity — the agent proposes, the engine disposes. The model is trusted with creativity, never correctness.
If the model hallucinates an illegal move, the downstream action group rejects it and the orchestrator’s fallback keeps the match alive. Keeping mechanics dumb and deterministic is exactly what lets the AI layer be bold.
EventBridge: the async boundary that makes it feel instant
The engine ends a turn by emitting two events:
TurnCompleted— wake the AI.MatchEvent— narrate what happened.
The instant those events hit the bus, the player’s request is done and the connection is free; the UI shows “AI thinking…” and waits for a push.
EventBridge is the seam that decouples UI latency from AI latency — the single most important architectural choice in the project. It does two things:
- Separates fast from slow — move validation (fast) is isolated from model inference (slow), so the user never pays for cold starts.
- Fans out — one move triggers both an agent invocation and commentary, independently, with zero coupling.
The payoff is graceful independence:
- If commentary is slow or Polly is down, the move still completes.
- If the agent is slow, the caption still shows.
That independence is the difference between a platform that feels alive and one that stutters — and because everything past this point is asynchronous, it also scales cheaply.
The orchestrator: deterministic routing for non-deterministic agents
Agents are non-deterministic. Routing should never be. The orchestrator is a small Lambda triggered by
TurnCompleted that does four jobs and nothing more:- Map game → agent runtime ARN with a static dictionary. Adding a game is one entry; routing is code, not a model decision.
- Read opponent memory and attach a one-line note to the agent payload.
- Select the Nova tier from the match’s difficulty.
- Invoke AgentCore with retry and fallback, so a cold start or a throttle never stalls a match.
1
2
3
4
5
6
7
8
for attempt in range(3):
try:
core.invoke_agent_runtime(agentRuntimeArn=arn, runtimeSessionId=match_id,
payload=json.dumps(payload).encode())
return {"ok": True, "attempt": attempt}
except Exception as e:
last_err = e
raise RuntimeError(f"Agent invoke failed after retries: {last_err}")The detail that matters most is
runtimeSessionId=match_id:- Every match maps to its own isolated agent session, so reasoning context stays per-match.
- Thousands of games can run concurrently without cross-talk.
The orchestrator is where determinism meets intelligence — code decides who runs and with what, the agent decides what to do. By keeping all the boring-but-critical reliability logic here, the agents themselves stay tiny.
Amazon Bedrock AgentCore Runtime: where the agents actually live
A subtle but important choice: the agents are not ad-hoc Bedrock calls scattered through Lambdas. They live on AgentCore Runtime, which hosts each game’s agent as an isolated process with its own:
- Entrypoint — a single
invokefunction. - Scaling — managed per agent.
- Session boundary — one per match.
The orchestrator hands AgentCore a payload (board or units,
modelProfile, opponent note); AgentCore runs the agent, lets it call tools, and returns when the turn is done. A game agent is deliberately tiny — a system prompt, a model resolved from the profile, and a tool:1
2
3
4
5
6
7
8
9
10
11
app = BedrockAgentCoreApp()
def _agent(model_profile):
model_id = MODEL_MAP.get(model_profile, MODEL_MAP["medium_amazon"])
return Agent(model=BedrockModel(model_id=model_id), system_prompt=SYSTEM)
def invoke(payload):
response = str(_agent(payload.get("modelProfile", "easy_amazon"))(
f"Board={payload['board']}. {payload['opponent']} Make your move."
))Why AgentCore instead of raw model calls? Three things:
- Isolation — one session per match, keyed by
runtimeSessionId. - A clean tool boundary — the agent reasons, tools act.
- Observability — OTEL traces out of the box.
It’s the difference between “I called a model” and “I run an agent.” The platform treats every agent as a black box behind an ARN — exactly what the thin-shell philosophy wants: swappable brains.
Tools and action groups: how an agent changes the world
A model that only talks is useless in a game. Agents change the world through tools, and each tool is backed by an Action Group Lambda:
- Tic-Tac-Toe’s tool is
play_move. - Tactical’s is
unit_action(move|attack), called once per living unit.
When the agent calls the tool, the Lambda:
- Validates the order against the rules.
- Writes DynamoDB and flips the turn back to Human.
- Records the result if the game ended.
- Pushes new state over the socket.
- Emits a fresh
MatchEventso the commentator reacts.
This is the second safety net. The model is free to propose anything, but the action group is the gatekeeper — occupied cell, dead target, off-board move are all rejected. So the model can be aggressive and clever without ever breaking the game. Notice the symmetry: human moves are gated by the engine, AI moves are gated by the action group — same rules, both sides. Tools are how intelligence becomes effect, and where you concentrate trust.
Agent-to-agent (A2A): the Strategist and the Executor
Tic-Tac-Toe is simple enough for one agent. Tactical Arena is not — coordinating three units against three enemies, with positioning, focus-fire, and range management, is genuine planning. So Tactical uses two agents:
- Strategist— reads the unit list and writes a short battle plan: “focus the low-HP Striker, Tank advances, Support holds range.”
- Executor — takes that plan and emits the concrete
unit_actiontool calls.
1
2
3
4
5
def _plan(model_profile, units):
if model_profile.startswith("easy_"):
return ""
strategist = _agent(model_profile, STRATEGIST) # same model profile as the executor
return str(strategist(f"Units={units}. Plan the squad's turn."))This is A2A collaboration in its simplest, most useful form — think, then act. The wins:
- The Strategist uses the same model profile as the executor, so the player’s choice governs both planning and execution — Claude Opus plans and executes on hard_claude; Nova Pro handles both on hard_amazon.
- The Executor follows the plan and does the high-volume tool-calling.
- Result: noticeably stronger hard-mode play, and the model family stays consistent across the whole A2A chain.
- It’s optional — easy profiles skip planning, so compute tracks difficulty.
A2A here isn’t two agents arguing; it’s a clean division of labor, which is exactly what makes it cheap and reliable enough to ship.
And A2A doesn’t stop at playing. A third agent — the Coach — watches the same
MatchEvent stream and, on your own moves, returns one short teaching tip:- It plays no moves and changes no state — a pure consumer of the existing contracts that adds a teaching voice over the same event bus.
- The same
coach_tip()function backs both the EventBridge handler and the MCPexplain_movetool, so coaching behaves identically whether the platform fires it or another agent pulls it. - Adding it took one new Lambda and zero changes to the games — the clearest proof that new agent roles plug in rather than getting wired in.
MCP: turning capabilities into shared, reusable tools
This is where the architecture stops being one app and starts being a platform. Reusable capabilities aren’t hard-wired into either game — they’re published through a single Model Context Protocol (MCP) catalog that any agent can connect to and discover.
1
2
3
4
5
6
7
8
9
10
11
12
13
mcp = FastMCP("game-platform")
def generate_commentary(event): return commentate(event) # commentary
def recall_player(username, game): # memory
return {"note": profile_note(username, game), "profile": get_profile(username, game)}
def top_players(game, difficulty, limit=10): return top(game, difficulty, limit) # leaderboard
def analyze_position(game, state): return analyze(game, state) # board analysis
def explain_move(summary): return coach_tip(summary) # coachingMCP is the USB-C of agent tooling: a standard way to expose capabilities so any agent — these games or ones I haven’t built yet — can connect and discover commentary, memory, leaderboard, analysis, and coaching tools with zero code changes.
- Add Chess next month and it gets an textual commentator, a coach, and player memory for free, just by talking to the catalog.
- The same functions back both the EventBridge handlers and the MCP tools, so behavior is identical whether a capability is fired by the platform or pulled by an agent.
- Capabilities become portable instead of duplicated, and the catalog of what your agents can do grows without coupling.
That’s the difference between a feature and a building block.
Model selection as a real dial
Difficulty isn’t fake thinking delays — it’s literally which model reasons. The UI now exposes six explicit profiles, letting the player pick not just a difficulty level but also the AI provider: Amazon Nova or Anthropic Claude. Each profile maps to a concrete Bedrock model or inference profile ID, with env-var overrides for tuning.
Frontend optionTic-Tac-ToeTacticaleasy_amazonNova MicroNova Liteeasy_claudeClaude Haiku 4.5Claude Haiku 4.5medium_amazonNova LiteNova Micromedium_claudeClaude Sonnet 4.6Claude Sonnet 4.6hard_amazonNova ProNova Prohard_claudeClaude Opus 4.6Claude Opus 4.6
The selected
modelProfile flows engine → match record → orchestrator → agent payload, where MODEL_MAP resolves it to the right model:1
2
3
4
5
6
7
8
MODEL_MAP = {
"easy_amazon": "amazon.nova-micro-v1:0",
"easy_claude": "us.anthropic.claude-haiku-4-5-20251001-v1:0",
"medium_amazon": "amazon.nova-lite-v1:0",
"medium_claude": "global.anthropic.claude-sonnet-4-6",
"hard_amazon": "amazon.nova-pro-v1:0",
"hard_claude": "global.anthropic.claude-opus-4-6-v1",
}Claude profiles require a Bedrock inference profile ID (the
us.* or global.* form), not a plain on-demand model ID. If your account or region differs, replace the default with the exact profile ARN from your Bedrock console.Hard mode additionally unlocks the A2A Strategist. One dial, real consequences — cheaper and snappier on easy_amazon, deeper and pricier on hard_claude.
How the AI layers fit together
Each AI piece is interesting on its own, but the real value is how they stack. It’s worth seeing them as a layered cake rather than a list, because each layer hands a smaller, cleaner problem to the next.

Read it bottom-up — each layer narrows the problem for the next:
- Memory is the slowest-moving layer — it accumulates across matches and changes who the AI thinks you are.
- The orchestrator reads that memory, turns a difficulty string into a concrete model plus an isolated session, and absorbs the messy reliability concerns (retries, fallbacks, routing) so nothing above it has to.
- The Strategist consumes a clean payload and produces intent.
- The Executor consumes intent and produces validated moves.
- The action group consumes moves and produces durable state.
The chain is: memory → context, context → a plan, a plan → moves, moves → state. By the bottom, “play a good turn” has been decomposed into four jobs no single prompt could do well.
The layers cooperate without knowing about each other:
- The Strategist doesn’t know commentary exists.
- The commentator doesn’t know there’s a Strategist.
- Memory doesn’t know which model is reasoning.
They communicate through three neutral, data-shaped interfaces — the event bus, the DynamoDB match, and the agent payload — and that’s the whole trick. Because the contracts are data, not function calls, I can drop in a coach agent or a second commentator and the existing layers neither notice nor break. Stack the three enabling properties and you get something that behaves like a platform — new intelligence plugs in, it doesn’t get wired in:
- AgentCore makes each brain swappable.
- MCP makes each capability shareable.
- EventBridge makes each layer independently triggerable.
And there’s a feedback loop hiding in the diagram: the action group writes results that update memory → memory feeds the orchestrator → the orchestrator shapes the next agent turn → the commentator reacts to it all. Over many matches the AI’s behavior is a function of your history, not just the current board — which is why a 12-game opponent feels different from a stranger. None of the individual layers is complicated; the composition is what produces the personality.
Putting it together: one move, every component
SPA → WebSocket → Engine (validate, memory, events) → EventBridge → Orchestrator (route + tier + memory + retry) → AgentCore agent (+Strategist for Tactical) → Action group (apply, push, leaderboard) → Commentary (Nova + Polly + MCP) → UI. Two async branches, one move, fully decoupled — the whole platform in a single round trip.

Cost, security, and operations
- Pay-per-use — DynamoDB on-demand, Lambda, AgentCore, Nova, Polly. No idle cost.
- Rules in code — clients can’t tamper; the action group is a second gate.
- Per-match isolation — each match is its own AgentCore session keyed by
matchId. - Decoupled commentary — never blocks moves; audio is base64 over the existing socket.
- Observability — X-Ray plus AgentCore OTEL traces show exactly where a slow turn went.
Live demo: Amazon Nova Micro vs Claude Haiku 4.5 in Tic-Tac-Toe
To make the model-profile difference tangible, I ran both
hard_amazon (Amazon Nova Pro) and easy_claude (Claude Haiku 4.5) head-to-head against the same human player. The contrast was striking.Amazon Nova Pro— the human wins
In practice it shows: the model sometimes picks a suboptimal cell, misses a blocking move, and lets the human build a winning line without putting up much resistance. It plays fast and cheaply, which is exactly what you want when you’re learning the controls or just want a quick win — but a confident player can beat it consistently.




Claude Haiku 4.5 — every game is a draw
Claude Haiku 4.5, even at easy difficulty, applies near-perfect defensive play. It won’t make an outright blunder; any line the human builds gets blocked before it completes. But it also never sacrifices safety for aggression, so it rarely lands a winning move itself. The result is that virtually every game ends in a draw — the human can’t win and the AI won’t push for the kill.




Note : Both these model has been executed on same piece of code, the only difference is the model.
What the comparison reveals
This isn’t a flaw in either model — it’s the point of offering both. The six selectable profiles span a real spectrum:
- Nova Micro / easy_amazon — beatable, forgiving, cheap. Great for onboarding.
- Claude Haiku 4.5 / easy_claude — defensively sound, never loses. A good “training partner” that forces you to think without punishing mistakes.
- Higher profiles (Nova Pro, Claude Opus) add aggression, deeper look-ahead, and — on Tactical — multi-unit coordination through the A2A Strategist.
The same
MODEL_MAP lookup and modelProfile field in the payload is all that separates these behaviors. No branching logic, no special-cased prompts — just a different Bedrock model ID and the emergent reasoning that comes with it. Model selection is the feature.Your time matters.! I hope this content serves you well in your projects and learning journey. If it helped you in any way, that’s all the reward I need.
All the code is there in GitHub repo:
GitHub Repo
Thank you for your time and attention.
“Keep Learning.. Keep growing..”
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article