Overview
You will learn
- How
@flow.statusturns a CDS entity into a state machine - How
@fromand@toannotations enforce valid state transitions (HTTP 409 on violation) - How
$flow.previousrestores a prior state without storing it yourself - How to implement multiplayer turn rotation with CAP in-process events
- How to expose CAP actions as MCP tools for AI-driven gameplay
Prerequisites
- Node.js 22 or later (download)
- VS Code (download)
- SAP CDS Language Support extension (
sapse.vscode-cds) installed in VS Code
Steps
Intro
Snake & Ladder is a perfect model for a state machine: every player position is a state, and every move, snake bite, and ladder climb is a transition. CAP’s @flow.status feature maps directly to this model.
In this tutorial you build the complete backend engine for a multiplayer Snake & Ladder game — a real CAP Node.js service with entities, status flows, event-driven turn rotation, and an AI-accessible MCP server. The game uses a classic 10×10 board with 9 ladders, 3 single-headed snakes, and 3 double-headed snakes (where two head squares share one tail).
By the end you will have:
- A running CAP service at
http://localhost:4004 - A multiplayer game engine enforcing turn order and state transitions automatically
- A board UI you can open in a browser and play
- An MCP server that lets an AI assistant roll dice and narrate turns
Make sure you have the three prerequisites above installed. Then install the CAP development toolkit globally:
npm install -g @sap/cds-dkVerify the installation:
cds vYou should see @sap/cds-dk at 10.x. Now initialise your CAP project:
cds init snake-ladder --nodejs
cd snake-ladderRemove
"type": "module"before continuing.cds init --nodejsadds"type": "module"topackage.json, which switches Node.js to ES module mode. The handlers in this tutorial use CommonJSrequire()— if you skip this step the server crashes at startup withrequire is not defined in ES module scope. Openpackage.json, delete the"type": "module"line, and save the file now, before runningnpm install.
npm install
npm install @sap/cds@latest @cap-js/sqlite@latestOpen the snake-ladder folder in VS Code:
code .Your project structure should look like this (the generator also adds app/, .vscode/, .gitignore, and readme.md — you can ignore those):
snake-ladder/
app/
db/
srv/
package.jsonCreate db/schema.cds with the CDS data model. The centrepiece is the TurnStatus enum — a five-state machine that controls what each player can do on their turn.
namespace snakeladder;
// ── Status enum — the heart of the state machine ─────────────────────────
type TurnStatus : String enum {
Waiting; // Sitting out — waiting for the current player to finish
Playing; // Active turn — roll the dice!
Moving; // Dice rolled, token moving — confirm or handle special square
Blocked; // Hit a double-headed snake — skip next turn
Finished; // Reached square 100 and won
}
// ── Board square — pre-loaded with snake/ladder destinations ──────────────
entity BoardSquares {
key square : Integer; // 1–100
snakeTo : Integer; // 0 if no snake on this square
ladderTo : Integer; // 0 if no ladder on this square
isDoubleHead: Boolean default false; // true = double-headed snake mouth
}
// ── Game session ──────────────────────────────────────────────────────────
entity GameSessions {
key ID : UUID;
name : String(100);
@cds.on.insert: $now
createdAt : Timestamp;
startedAt : Timestamp;
finishedAt : Timestamp;
winner : Association to Players;
}
// ── Player — one row per player per session ───────────────────────────────
entity Players {
key ID : UUID;
session : Association to GameSessions;
name : String(100);
position : Integer default 1; // current square (1–100)
turnStatus : TurnStatus default #Waiting;
turnOrder : Integer; // 1 = goes first
lastRoll : Integer; // last dice result
prevPosition : Integer default 1; // position before last roll
lastEventType : String(20); // normal | ladder | snake | doubleSnake
}Why
@flow.statusis declared in the service, not the schema:@flow.statusis a service-layer concern — it controls which HTTP actions are allowed based on current status. The db schema just stores the field. The annotation goes on the service projection in the next step.
Field naming convention: In CDS, entity names are PascalCase (
Players,GameSessions) and element names are camelCase (turnStatus,turnOrder). NoticeturnStatusis camelCase even though its typeTurnStatusis PascalCase.
Create srv/game-service.cds. The @flow.status annotation goes on the service projection — this is where CAP intercepts HTTP requests and enforces state transitions.
using { snakeladder } from '../db/schema';
service GameService {
entity GameSessions as projection on snakeladder.GameSessions;
@flow.status: turnStatus
@cds.redirection.target // canonical projection of Players — see the MCP step
entity Players as projection on snakeladder.Players {
*,
@readonly position,
@readonly turnStatus
}
actions {
// ── Waiting → Playing ──────────────────────────────────────────────────
@from: [ #Waiting ]
@to: #Playing
action startTurn();
// ── Playing → Moving ───────────────────────────────────────────────────
@from: [ #Playing ]
@to: #Moving
action rollDice() returns { roll: Integer; position: Integer; event: String; };
// ── Moving → Waiting ───────────────────────────────────────────────────
@from: [ #Moving ]
@to: #Waiting
action confirmMove();
// ── Moving → Blocked (double-headed snake) ─────────────────────────────
@from: [ #Moving ]
@to: #Blocked
action blockPlayer();
// ── Blocked → previous state ───────────────────────────────────────────
@from: [ #Blocked ]
@to: $flow.previous
action unblockPlayer();
// ── Moving → Finished ──────────────────────────────────────────────────
@from: [ #Moving ]
@to: #Finished
action winGame();
}
entity BoardSquares as projection on snakeladder.BoardSquares;
// ── Domain events ─────────────────────────────────────────────────────────
event BoardEvent {
playerID : UUID;
type : String; // 'ladder' | 'snake' | 'doubleSnake'
![from] : Integer; // 'from' is a CDS reserved word — escape with ![]
![to] : Integer;
}
event GameWon {
playerID : UUID;
sessionID : UUID;
}
}What CAP does automatically with @from / @to:
Before your handler runs, CAP checks the player’s current turnStatus against the @from list. If the status does not match, it returns:
HTTP/1.1 409 Conflict
{
"error": {
"code": "INVALID_FLOW_TRANSITION_SINGLE",
"message": "Action \"rollDice\" requires \"turnStatus\" to be \"[\"Playing\"]\"."
}
}No custom validation code. No if (status !== 'Playing') throw .... The annotation enforces it.
After your handler returns without throwing, CAP automatically updates turnStatus to the @to value.
$flow.previousis a special target used byunblockPlayer. Instead of a fixed state, it restores whatever state the player was in before they transitioned to#Blocked. CAP tracks this in the background — you don’t need apreviousStatusfield.
@readonlyon service projection: Placing@readonlyhere means OData/REST clients cannot directly PATCHpositionorturnStatus. They can only change these values by calling the proper bound actions. Your handler code (usingUPDATE) is not affected —@readonlyonly applies to inbound HTTP writes.
Create db/data/snakeladder-GameSessions.csv:
ID,name,createdAt,startedAt,finishedAt,winner_ID
a1b2c3d4-0001-0000-0000-000000000000,Demo Game,,Create db/data/snakeladder-BoardSquares.csv with all 100 squares. The board has 9 ladders, 3 single-headed snakes, and 3 double-headed snakes. Double-headed snake heads have isDoubleHead: true — two head squares share one tail:
square,snakeTo,ladderTo,isDoubleHead
1,0,0,false
2,0,38,false
3,0,0,false
4,0,0,false
5,0,0,false
6,0,0,false
7,0,0,false
8,0,31,false
9,0,0,false
10,0,0,false
11,0,0,false
12,0,0,false
13,0,0,false
14,0,0,false
15,0,26,false
16,0,0,false
17,0,0,false
18,0,0,false
19,0,0,false
20,0,0,false
21,0,42,false
22,0,0,false
23,0,0,false
24,0,0,false
25,0,0,false
26,0,0,false
27,0,0,false
28,0,84,false
29,0,0,false
30,0,0,false
31,0,0,false
32,0,0,false
33,0,0,false
34,0,0,false
35,0,0,false
36,0,44,false
37,0,0,false
38,0,0,false
39,0,0,false
40,3,0,true
41,0,0,false
42,0,0,false
43,18,0,false
44,0,0,false
45,0,0,false
46,0,0,false
47,3,0,true
48,0,0,false
49,0,0,false
50,0,0,false
51,0,67,false
52,0,0,false
53,0,0,false
54,34,0,true
55,0,0,false
56,0,0,false
57,0,0,false
58,0,0,false
59,0,0,false
60,0,0,false
61,0,0,false
62,0,0,false
63,34,0,true
64,0,0,false
65,0,0,false
66,0,0,false
67,0,0,false
68,0,0,false
69,0,0,false
70,0,0,false
71,0,91,false
72,50,0,true
73,0,0,false
74,0,0,false
75,0,0,false
76,20,0,false
77,0,0,false
78,0,98,false
79,0,0,false
80,0,0,false
81,0,0,false
82,0,0,false
83,0,0,false
84,0,0,false
85,0,0,false
86,0,0,false
87,0,0,false
88,0,0,false
89,50,0,true
90,0,0,false
91,0,0,false
92,0,0,false
93,0,0,false
94,0,0,false
95,0,0,false
96,0,0,false
97,0,0,false
98,0,0,false
99,7,0,false
100,0,0,falseDouble-headed snake pairs: Squares 40+47 → tail 3; squares 54+63 → tail 34; squares 72+89 → tail 50. Landing on either head sends you to the shared tail.
Now start the service:
cds watchYou should see: serving GameService { at: '/odata/v4/game' }.
Service path: CAP derives the OData path from the service name.
GameServicemaps to/odata/v4/game(lowercase, “Service” suffix stripped). Use this path for all OData requests.
Open http://localhost:4004 and verify GameService appears with GameSessions, Players, and BoardSquares endpoints.
Test that the board data loaded correctly:
GET http://localhost:4004/odata/v4/game/BoardSquares?$filter=snakeTo gt 0You should receive exactly 9 rows — the 9 snake-head squares (3 single-headed + 6 double-headed).
Typing OData URLs by hand gets tedious — especially POST requests with a full JSON body. CAP can generate a ready-to-run request file for your service with a single command:
cds add httpThis creates test/http/GameService.http with one request per entity (GET, POST, PATCH, DELETE), pre-filled with sample values, plus a request for each unbound action or function. The file uses the REST Client format (abridged excerpt):
@server=http://localhost:4004
@username=alice
@password=
# @name BoardSquares_GET
GET {{server}}/odata/v4/game/BoardSquares
Authorization: Basic {{username}}:{{password}}
# @name Players_POST
POST {{server}}/odata/v4/game/Players
Content-Type: application/json
Authorization: Basic {{username}}:{{password}}
{
"name": "sample",
"turnOrder": 1
}Each request in the real file is separated by a
###line followed by a# @namelabel — that is the REST Client request-separator syntax, not a tutorial heading.
Bound actions are not generated.
cds add httponly writes requests for entities and unbound actions. Every action in this game —startTurn,rollDice,confirmMove,blockPlayer,unblockPlayer— is bound to an entity, so none of them appear in the file. You call a bound action with aPOSTto the entity’s key path. Create a player with the generatedPlayers_POSTrequest first, then add a request like this to the file yourself (paste in theIDthe create returned):HTTP# @name rollDice POST {{server}}/odata/v4/game/Players(ID=<paste-a-player-id>)/rollDice Content-Type: application/json Authorization: Basic {{username}}:{{password}} {}
Install the REST Client extension (
humao.rest-client) to run these requests directly in VS Code. A Send Request link appears above each request — click it to fire the request and see the response in a split pane. Thealiceuser with an empty password matches CAP’s mocked authentication, so no real credentials are needed.
Test a sample endpoint: with cds watch running, open test/http/GameService.http and click Send Request above the BoardSquares_GET request. You should get all 100 board squares back — the same result as the manual GET above, now one click away.
You will reuse this file throughout the rest of the tutorial: use the generated GameSessions_POST and Players_POST requests to create a game and its players, and the bound-action template above to drive turns. After adding more entities in later steps, re-run cds add http to refresh the file. (Once you add the MCP protocol near the end, the generator also writes a second set of requests under the /mcp/game path — ignore those for REST testing and keep using the /odata/v4/game requests.)
Create srv/game-service.js. This file provides the JavaScript implementation for your CDS-declared actions. Start with just the rollDice handler — it contains the core game logic.
const cds = require('@sap/cds')
module.exports = class GameService extends cds.ApplicationService {
async init() {
const { Players, BoardSquares, TurnLog } = this.entities // TurnLog used in the multiplayer step
// ── startTurn ─────────────────────────────────────────────────────────
// @from: [#Waiting] → @to: #Playing
// No business logic needed — the @flow.status annotations do all the work.
this.on('startTurn', 'Players', async (req) => {
// Intentionally empty.
})
// ── rollDice ──────────────────────────────────────────────────────────
// @from: [#Playing] → @to: #Moving
this.on('rollDice', 'Players', async (req) => {
const player = await SELECT.one.from(Players).where({ ID: req.params[0].ID })
if (!player) return req.reject(404, 'Player not found')
const roll = Math.ceil(Math.random() * 6)
const newPosition = player.position + roll
// Can't overshoot square 100 — must land exactly
if (newPosition > 100) {
const needed = 100 - player.position
return req.reject(409, `Need exactly ${needed} to finish. Rolled ${roll}. No move.`)
}
// Resolve snake or ladder on the landing square
const square = await SELECT.one.from(BoardSquares).where({ square: newPosition })
let finalPosition = newPosition
let event = null
if (square?.ladderTo) {
finalPosition = square.ladderTo
event = { type: 'ladder', from: newPosition, to: finalPosition }
} else if (square?.snakeTo) {
finalPosition = square.snakeTo
event = square.isDoubleHead
? { type: 'doubleSnake', from: newPosition, to: finalPosition }
: { type: 'snake', from: newPosition, to: finalPosition }
}
// Persist position, last roll, and pre-move state for TurnLog
await UPDATE(Players)
.set({
position: finalPosition,
lastRoll: roll,
prevPosition: player.position,
lastEventType: event?.type ?? 'normal'
})
.where({ ID: player.ID })
// Emit domain event so subscribers can react
if (event) await this.emit('BoardEvent', { playerID: player.ID, ...event })
// Use req.reply() — not return — to pin the response before CAP's
// @flow.status after-handler runs. A plain return gets cleared by the
// flow machinery, producing HTTP 204 instead of the expected 200 + body.
req.reply({ roll, position: finalPosition, event: event?.type ?? 'normal' })
})
return super.init()
}
}Test the state machine:
Start cds watch. Create a player via the Fiori UI at http://localhost:4004 or with a POST request:
POST http://localhost:4004/odata/v4/game/Players
Content-Type: application/json
{
"session_ID": "a1b2c3d4-0001-0000-0000-000000000000",
"name": "Test Player",
"turnOrder": 1
}Set the player to Playing:
POST http://localhost:4004/odata/v4/game/Players(<player-id>)/startTurn
Content-Type: application/json
{}Roll the dice:
POST http://localhost:4004/odata/v4/game/Players(<player-id>)/rollDice
Content-Type: application/json
{}Expected response:
{
"roll": 4,
"position": 38,
"event": "ladder"
}The position field is the final square after any snake or ladder — so if you rolled onto a ladder, position already shows the top of the ladder.
Prove the 409 rejection:
Without calling confirmMove, call rollDice again. CAP returns:
{
"error": {
"code": "INVALID_FLOW_TRANSITION_SINGLE",
"message": "Action \"rollDice\" requires \"turnStatus\" to be \"[\"Playing\"]\"."
}
}This is the @from: [#Playing] annotation at work. Your handler code never ran — CAP rejected the request before it reached you.
Add these handlers to srv/game-service.js, before the return super.init() line. (You will add the confirmMove handler later, in the turn-rotation step, once there is more than one player to hand the turn to.)
// ── blockPlayer ───────────────────────────────────────────────────────
// @from: [#Moving] → @to: #Blocked
// Called when a player lands on a double-headed snake head.
this.on('blockPlayer', 'Players', async (req) => {
// The position was already set by rollDice.
// This action just transitions state — CAP handles @to: #Blocked.
req.info('Player is blocked and will skip their next turn.')
})
// ── unblockPlayer ─────────────────────────────────────────────────────
// @from: [#Blocked] → @to: $flow.previous
// $flow.previous restores the status before the transition to #Blocked.
this.on('unblockPlayer', 'Players', async (req) => {
// CAP's $flow.previous does the work — no code needed here.
})
// ── winGame ───────────────────────────────────────────────────────────
// @from: [#Moving] → @to: #Finished
this.on('winGame', 'Players', async (req) => {
const player = await SELECT.one.from(Players).where({ ID: req.params[0].ID })
if (!player) return req.reject(404, 'Player not found')
await UPDATE('snakeladder.GameSessions')
.set({
finishedAt: new Date().toISOString(),
winner_ID: player.ID
})
.where({ ID: player.session_ID })
await this.emit('GameWon', { playerID: player.ID, sessionID: player.session_ID })
})Test $flow.previous:
Position a player at square 88 (one roll from square 89, a double-headed snake head):
Note:
positionis@readonlyin the service projection. To set it for testing, temporarily remove@readonlyfrompositioninsrv/game-service.cdsand save —cds watchrestarts automatically. PATCH the player, then restore@readonlyand save again (another automatic restart).
PATCH http://localhost:4004/odata/v4/game/Players(<player-id>)
Content-Type: application/json
{ "position": 88 }Start a turn and roll. If you roll 1, you land on square 89 (a double-headed snake head). Your position moves to 50. The event field in the response is "doubleSnake".
Now call blockPlayer — the player’s turnStatus becomes "Blocked". Call unblockPlayer — the status returns to "Moving" automatically. That is $flow.previous at work: CAP restored the state the player had before the transition to #Blocked.
So far, GameSessions has no state machine — you can’t tell whether a game is waiting for players or in progress. Add a SessionStatus enum and a second @flow.status annotation.
Update db/schema.cds — add the SessionStatus enum and update GameSessions:
type SessionStatus : String enum {
Lobby; // Session created, accepting players
InProgress; // Game running — turns are active
Complete; // A player has reached square 100
}
entity GameSessions {
key ID : UUID;
name : String(100);
@cds.on.insert: $now
createdAt : Timestamp;
startedAt : Timestamp;
finishedAt : Timestamp;
winner : Association to Players;
sessionStatus : SessionStatus default #Lobby;
}Add TurnLog entity to db/schema.cds (records every move):
entity TurnLog {
key ID : UUID;
session : Association to GameSessions;
player : Association to Players;
turnNumber : Integer;
diceRoll : Integer;
fromSquare : Integer;
toSquare : Integer;
eventType : String(20); // normal | ladder | snake | doubleSnake | win | blocked
timestamp : Timestamp;
}Update srv/game-service.cds — replace the plain GameSessions projection with a flow-annotated one, and add TurnLog and TurnComplete:
@flow.status: sessionStatus
entity GameSessions as projection on snakeladder.GameSessions {
*,
@readonly sessionStatus
}
actions {
@from: [ #Lobby ]
@to: #InProgress
action startGame();
@from: [ #InProgress ]
@to: #Complete
action endGame();
}
entity TurnLog as projection on snakeladder.TurnLog;
event TurnComplete {
sessionID : UUID;
playerID : UUID;
nextPlayerID : UUID;
turnNumber : Integer;
}You now have two independent state machines in one service:
| Entity | Field | States | Purpose |
|---|---|---|---|
GameSessions | sessionStatus | Lobby → InProgress → Complete | Game lifecycle |
Players | turnStatus | Waiting → Playing → Moving → Blocked / Finished | Per-player turn |
Add the startGame and endGame handlers, and the full confirmMove handler (which drives turn rotation).
Add to srv/game-service.js (before return super.init()):
// ── startGame ─────────────────────────────────────────────────────────
// @from: [#Lobby] → @to: #InProgress
this.on('startGame', 'GameSessions', async (req) => {
const sessionID = req.params[0].ID
const players = await SELECT.from(Players)
.where({ session_ID: sessionID })
.orderBy('turnOrder')
if (players.length < 2)
return req.reject(400, 'A game needs at least 2 players to start.')
// First player in turn order becomes Playing
await UPDATE(Players)
.set({ turnStatus: 'Playing' })
.where({ ID: players[0].ID })
await UPDATE('snakeladder.GameSessions')
.set({ startedAt: new Date().toISOString() })
.where({ ID: sessionID })
})
// ── endGame ───────────────────────────────────────────────────────────
// @from: [#InProgress] → @to: #Complete
this.on('endGame', 'GameSessions', async (req) => {
await UPDATE('snakeladder.GameSessions')
.set({ finishedAt: new Date().toISOString() })
.where({ ID: req.params[0].ID })
})
// ── confirmMove ───────────────────────────────────────────────────────
// @from: [#Moving] → @to: #Waiting — advances the turn to the next player
// @from: [#Moving] → @to: #Waiting
this.on('confirmMove', 'Players', async (req) => {
const player = await SELECT.one.from(Players).where({ ID: req.params[0].ID })
if (!player) return req.reject(404, 'Player not found')
// Delegate to winGame if player reached square 100
if (player.position === 100) {
await this.send({ event: 'winGame', entity: 'Players', params: [{ ID: player.ID }] })
return
}
// Write TurnLog entry
const [{ n: count }] = await SELECT`count(*) as n`
.from(TurnLog).where({ session_ID: player.session_ID })
const turnNumber = (count || 0) + 1
await INSERT.into(TurnLog).entries({
ID: cds.utils.uuid(),
session_ID: player.session_ID,
player_ID: player.ID,
turnNumber,
diceRoll: player.lastRoll,
fromSquare: player.prevPosition,
toSquare: player.position,
eventType: player.lastEventType ?? 'normal',
timestamp: new Date().toISOString()
})
// Find next player in turn order, skipping Finished players
const players = await SELECT.from(Players)
.where({ session_ID: player.session_ID }).orderBy('turnOrder')
const idx = players.findIndex(p => p.ID === player.ID)
let nextIdx = (idx + 1) % players.length
let guard = 0
while (players[nextIdx].turnStatus === 'Finished' && guard++ < players.length)
nextIdx = (nextIdx + 1) % players.length
const next = players[nextIdx]
await this.emit('TurnComplete', {
sessionID: player.session_ID,
playerID: player.ID,
nextPlayerID: next.ID,
turnNumber
})
// Activate next player directly
await UPDATE(Players).set({ turnStatus: 'Playing' }).where({ ID: next.ID })
})
this.send()object form: When delegating to a bound entity action from inside a handler, always use the object form:this.send({ event, entity, params }). The string formthis.send('winGame', {...})puts{...}intoreq.data, notreq.params, soreq.params[0].IDin the target handler will beundefined.
Test multiplayer:
POST http://localhost:4004/odata/v4/game/GameSessions
Content-Type: application/json
{ "name": "My First Multiplayer Game" }POST http://localhost:4004/odata/v4/game/Players
Content-Type: application/json
{ "session_ID": "<session-id>", "name": "Alice", "turnOrder": 1 }POST http://localhost:4004/odata/v4/game/Players
Content-Type: application/json
{ "session_ID": "<session-id>", "name": "Bob", "turnOrder": 2 }POST http://localhost:4004/odata/v4/game/GameSessions(<session-id>)/startGame
Content-Type: application/json
{}Alice should now be "Playing", Bob "Waiting". Roll for Alice and call confirmMove — Bob should automatically become "Playing".
The TurnComplete event was emitted by confirmMove in the previous step. Add a subscriber that logs the handoff.
Add to srv/game-service.js (before return super.init()):
// ── TurnComplete subscriber ───────────────────────────────────────────
// this.after() on a custom event receives (_results, req).
// The first argument is undefined (events have no return value).
// Access the payload via req.data.
this.after('TurnComplete', async (_results, req) => {
const { sessionID, nextPlayerID, turnNumber } = req.data
console.log(`[Turn ${turnNumber}] session=${sessionID} → next player=${nextPlayerID}`)
})CDS 10 event handler signature: In CDS 10,
this.after()for custom in-process events receives(_results, req)where the first argument isundefined. The payload is inreq.data. Using{ data }destructuring as the first argument (a common mistake) will result indatabeingundefined.
After saving, cds watch auto-restarts. Run a full turn cycle — after confirmMove you should see the console log line appear.
The CAP MCP server exposes your service as tools that any MCP-compatible AI assistant can call — letting the AI roll dice, query board state, and narrate turns.
Install:
npm add @cap-js/mcpAnnotate srv/game-service.cds — add @mcp and @protocol to the service declaration, add descriptions, and add unbound wrapper functions:
/**
* Snake & Ladder Game Service.
* Players take turns rolling dice across a 10×10 board.
* Snakes send players back; ladders advance them. First to square 100 wins.
*/
@mcp
@protocol: ['odata-v4', 'mcp']
@title: 'Snake & Ladder Game'
service GameService {
// ... (keep all existing entity and action definitions unchanged)
// ── MCP-callable unbound functions ────────────────────────────────────
/** Start a game session. Requires a Lobby session with ≥ 2 players. */
function mcpStartGame(sessionID: UUID) returns String;
/** Roll the dice for the current Playing player. */
function mcpRollDice(playerID: UUID) returns { roll: Integer; position: Integer; event: String; };
/** Confirm a move and rotate the turn to the next player. */
function mcpConfirmMove(playerID: UUID) returns String;
/** Current board state — all player positions and statuses for a session. */
function mcpBoardState(sessionID: UUID) returns array of {
name: String; position: Integer; turnStatus: String; lastRoll: Integer;
};
/** All 100 squares with snake and ladder destinations. */
function mcpBoardMap() returns array of {
square: Integer; snakeTo: Integer; ladderTo: Integer; isDoubleHead: Boolean;
};
/** Live view of all player positions — filterable by session_ID. */
@readonly
entity BoardState as select from snakeladder.Players {
key ID,
name, position, turnStatus, lastRoll,
session.ID as session_ID : UUID,
session.name as sessionName
};
}
@protocol: ['odata-v4', 'mcp']is required. Without it, adding@mcpdrops the OData endpoint. Always include both protocols.
Why
@cds.redirection.targetonPlayers:BoardStateis a second projection ofsnakeladder.Playersin this service. That makes the associations pointing atPlayers(GameSessions.winner,TurnLog.player) ambiguous — CAP can’t tell whether they should redirect toPlayersorBoardState. The@cds.redirection.targetannotation you added to thePlayersprojection earlier marks it as the canonical target, resolving the ambiguity. Without it, compilation fails.
Add unbound function handlers to srv/game-service.js (before return super.init()):
this.on('mcpStartGame', async (req) => {
await this.send({ event: 'startGame', entity: 'GameSessions', params: [{ ID: req.data.sessionID }] })
return `Game started for session ${req.data.sessionID}`
})
this.on('mcpRollDice', async (req) => {
return await this.send({ event: 'rollDice', entity: 'Players', params: [{ ID: req.data.playerID }] })
})
this.on('mcpConfirmMove', async (req) => {
await this.send({ event: 'confirmMove', entity: 'Players', params: [{ ID: req.data.playerID }] })
return `Move confirmed for player ${req.data.playerID}`
})
this.on('mcpBoardState', async (req) => {
return await SELECT.from(Players)
.where({ session_ID: req.data.sessionID })
.columns('name', 'position', 'turnStatus', 'lastRoll')
.orderBy('turnOrder')
})
this.on('mcpBoardMap', async () => SELECT.from(BoardSquares).orderBy('square'))Enable per_action_tool in package.json — this gives each MCP function its own callable tool:
{
"cds": {
"mcp": { "per_action_tool": true }
}
}Restart cds watch. Look for:
serving GameService {
at: [ '/odata/v4/game', '/mcp/game' ],The MCP server is live at http://localhost:4004/mcp/game. It exposes 7 tools: describe, query, mcpStartGame, mcpRollDice, mcpConfirmMove, mcpBoardState, mcpBoardMap.
Test with MCP Inspector:
Run MCP Inspector in a second terminal (while cds watch is still running in the first):
npx @modelcontextprotocol/inspector --server-url http://localhost:4004/mcp/game --transport httpMCP Inspector opens at http://localhost:5173 in your browser, already pointed at your streamable-HTTP endpoint. You should see the Tools tab listing all 7 tools.
Verify each MCP function:
- Select
mcpBoardMap→ click Run → confirm you receive 100 square entries - Select
mcpBoardState, enter your session ID in thesessionIDparameter → confirm you see player positions - Select
mcpRollDice, enter a Playing player’s ID → confirm you receive{ roll, position, event }
If MCP Inspector is not available globally: the
npxcommand above downloads and runs it on demand — no global install needed.
Prefer the command line? MCP Inspector also has a CLI mode. Because the endpoint is streamable HTTP, you must pass
--transport httpexplicitly (without it the CLI errors withTransport type not specified):Shellnpx @modelcontextprotocol/inspector --cli http://localhost:4004/mcp/game --transport http --method tools/list
A ready-made board UI (board.html), its board image (board.png), and a verification script (verify.js) are included in this tutorial’s files.
Download the board UI files. Download these three files (right-click each link and choose Save Link As…):
- board.html — the single-page board UI
- board.png — the pre-rendered board image
- verify.js — the 14-check
Serve the board UI from CAP to avoid CORS errors. The board UI runs in the browser and fetches data from http://localhost:4004. If you open the HTML file straight from disk (a file:// URL) — or through a separate preview server such as VS Code’s Live Preview — the browser blocks those requests with a CORS error, because the page’s origin differs from the service’s origin. The clean fix is to let CAP serve the UI itself: anything placed in the project’s app/ folder is served at the server root, so the page and the OData service share the same origin and no CORS configuration is needed.
Place board.html and board.png in the project’s app/ folder, and verify.js in the project root:
cp <download-folder>/board.html app/board.html
cp <download-folder>/board.png app/board.png
cp <download-folder>/verify.js .
board.htmlloadsboard.pngwith a relative path, so both files must sit side by side inapp/.verify.jsis a Node.js script you run from the terminal (not served to the browser), so it stays in the project root.
If you are working remotely, use the VS Code Explorer to drag the downloaded files into place.
Wait for the reload before verifying. Copying files into the project triggers
cds watchto restart, and because the database runs in-memory that restart resets all data. Watch the terminal until you seeserver listening on ...again before running the board UI or the script below — if you runverify.jswhile the server is still restarting, a check can fail withfetch failedeven though your code is correct. If that happens, just run it again once the server has settled.
Run the board UI:
With cds watch still running, open http://localhost:4004/board.html in a browser. Because CAP serves the page from the same origin as the OData service, it connects without any CORS errors. The OData Service Root field is already filled with http://localhost:4004/odata/v4/game — click Connect, then + New Game to create a session, add two players, and start the game. You should see your player tokens on the board.
Run the verification script:
node verify.js http://localhost:4004/odata/v4/gameThe script runs 14 automated checks against your service:
| Check | What it validates |
|---|---|
| 1 | Service reachable (GET /$metadata returns 200) |
| 2 | BoardSquares loaded (exactly 100 rows) |
| 3 | Snake squares present (at least one snakeTo > 0) |
| 4 | Ladder squares present (at least one ladderTo > 0) |
| 5 | Double-headed snake squares (at least 6 isDoubleHead: true) |
| 6 | Create GameSession (POST /GameSessions returns 201) |
| 7 | Create Player 1 |
| 8 | Create Player 2 |
| 9 | startGame works (sessionStatus: InProgress) |
| 10 | First player is Playing after startGame |
| 11 | Wrong-state rejection (rollDice on #Waiting player returns HTTP 409) |
| 12 | rollDice works (returns roll 1–6, status → Moving) |
| 13 | Turn rotation (confirmMove makes Player 2 Playing) |
| 14 | $flow.previous (blockPlayer → #Blocked; unblockPlayer → previous status) |
All 14 checks must pass. If any fail, the script prints which check failed and what was expected vs. received.
Your service is complete and verified. The remaining steps check your understanding of the CAP status-flow concepts you used along the way. Start with where a state machine is declared.
In the service, @flow.status: turnStatus is placed on the Players projection, while @flow.status: sessionStatus is placed on GameSessions — each entity carries its own.
Every bound action lists the states it may run from, e.g. @from: [ #Moving ] on confirmMove. CAP checks the current status against that list before your handler code runs.
You called rollDice twice in a row (without confirmMove between them). The second call was rejected automatically, because the player was no longer in the #Playing state that rollDice requires.
Notice that your startTurn handler is intentionally empty, and your rollDice handler never assigns turnStatus — yet the status still advances after each call.
unblockPlayer is annotated @to: $flow.previous. After blockPlayer moves a player to #Blocked, unblockPlayer restores the earlier status.
Your service runs two state machines at once: Players.turnStatus (Waiting → Playing → Moving → Blocked / Finished) and GameSessions.sessionStatus (Lobby → InProgress → Complete). They advance independently.
On the Players projection, both position and turnStatus are annotated @readonly.
The verify.js script runs 14 automated checks against your service. One of them proves that CAP rejects an action called from the wrong state.
You now have a complete, verified Snake & Ladder engine built on CAP Status Flows. Here are two ways to take it further.
Play the SAP Community Devtoberfest 2026 game. Based on the framework and implementation you just finished in the Devtoberfest Snake & Ladder game with just some more features. Play with SAP Developer Advocate bots or invite a friend in the community and play remotely — scan the QR code or open https://url.sap/metpgf.

Try the July Developer Challenge. This tutorial grew out of the July 2026 SAP Developer Challenge — Snake & Ladder with SAP CAP Status Transition, where participants drove the same game with Claude Code and other MCP clients. Read the challenge write-up and give it a go: July Developer Challenge: Snake & Ladder with SAP CAP Status Transition.
Resources
Discussion
Share feedback on this tutorial or join the conversation in SAP Community.