Teknisk dokumentation för Asken Online
Version 1.1 • December 2025
Asken Online är en webbbaserad multiplayer-implementation av det klassiska svenska kortspelet "Asken" (även känt som "Sjuan" eller "Fan"). Spelet låter 3-7 spelare (människor och/eller robotar) tävla i realtid via webben.
Jag skapade detta spel för att kunna fortsätta spela Asken med min dotter som ska flytta till Australien eller Nya Zeeland för att bli Au Pair efter sin studentexamen (Det är planen i alla fall). Oavsett var i världen vi befinner oss kan vi nu spela vårt favoritkortspel tillsammans.
Applikationen följer en klassisk klient-server-arkitektur med websocket-kommunikation:
gameState-eventsAll speldata lagras i Redis, inte i serverns minne. Detta möjliggör server-restart utan att förlora pågående spel.
| Fil | Storlek | Beskrivning |
|---|---|---|
server/index.js |
~2400 rader | Express-server, Socket.io-handlers, spellogik, robot-AI |
public/index.html |
~3600 rader | All klientkod: HTML, CSS och JavaScript i en fil |
public/manual.html |
~700 rader | Spelregler och instruktioner |
public/about.html |
~1100 rader | Teknisk information |
Klienten är en Single Page Application (SPA) byggd med vanilla JavaScript. Allt innehåll (HTML, CSS, JS) finns i en enda fil för enkel deployment.
let gameState = {
code: "A1B2", // Rumskod
players: [...], // Spelararray
tableau: {...}, // Spelplan per färg
currentPlayerIndex: 0, // Vems tur
roundNumber: 1, // Aktuell runda
// ... mer state
};
:rootconst express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const Redis = require('ioredis');
const app = express();
app.use(express.static('public'));
const server = http.createServer(app);
const io = new Server(server);
// Spara rum med 48h TTL
async function saveRoom(room) {
await redis.setex(
`room:${room.code}`,
172800, // 48 timmar
JSON.stringify(room)
);
}
// Hämta rum
async function getRoom(code) {
const data = await redis.get(`room:${code}`);
return data ? JSON.parse(data) : null;
}
| Event | Riktning | Beskrivning |
|---|---|---|
createRoom |
Klient → Server | Skapa nytt spelrum |
joinRoom |
Klient → Server | Gå med i rum |
startGame |
Klient → Server | Starta spelet |
playCards |
Klient → Server | Spela kort |
pass |
Klient → Server | Passa (ta asken) |
gameState |
Server → Klient | Uppdatera spelstate |
error |
Server → Klient | Felmeddelande |
Socket.io hanterar all realtidskommunikation mellan klient och server. Varje spelare har en unik socket-anslutning som identifierar dem.
io.on('connection', (socket) => {
console.log('Spelare ansluten:', socket.id);
socket.on('disconnect', async () => {
// Hantera frånkoppling
// Markera spelare som disconnected
// Avsluta spel om nödvändigt
});
});
Varje gång spelstate ändras skickas en personlig gameState till varje spelare.
Varje spelare ser bara sina egna kort, medan motståndares kort visas som baksidor.
function emitRoomStateToAll(room) {
for (const player of room.players) {
const state = {
...roomData,
myId: player.id,
players: room.players.map(p => ({
...p,
hand: p.id === player.id ? p.hand : null,
cardCount: p.hand.length
}))
};
socket.emit('gameState', state);
}
}
Klienten skickar regelbundna keepalive-meddelanden för att
uppdatera lastActivity i rummet och förhindra timeout.
Spelare har 12 timmar på sig att återansluta efter en frånkoppling.
Matchmaking-systemet låter spelare hitta varandra utan att behöva dela rumskoder. En global kö hanteras i minnet på servern.
const matchmakingQueue = [];
// Format: { socketId, name, joinedAt }
function addToMatchmaking(socketId, name) {
matchmakingQueue.push({
socketId,
name,
joinedAt: Date.now()
});
broadcastMatchmakingState();
}
function getMatchmakingState() {
return {
queueCount: matchmakingQueue.length,
players: matchmakingQueue.map((p, i) => ({
name: p.name,
position: i + 1
}))
};
}
När minst 2 spelare finns i kön kan vem som helst starta spelet. Den som startar blir värd och alla i kön flyttas till ett nytt rum.
Spelarna kan kommunicera via en inbyggd chatt under spelets gång. Meddelanden skickas via Socket.io och visas för alla i rummet.
// Server
socket.on('chatMessage', async (text) => {
const cleanText = String(text).trim().slice(0, 200);
if (!cleanText) return;
io.to(currentRoom).emit('chatMessage', {
sender: playerName,
text: cleanText,
timestamp: Date.now()
});
});
// Klient
function addChatMessage(data) {
chatMessages.push(data);
if (!chatOpen) unreadCount++;
renderChatMessages();
}
const card = {
id: "hearts-7", // Unikt ID
suit: "hearts", // Färg
rank: 7 // Värde 1-13
};
const tableau = {
spades: { low: 5, high: 9 }, // 5-6-7-8-9
hearts: { low: 7, high: 7 }, // Bara 7:an
diamonds: { low: 3, high: 10 }, // 3 till 10
clubs: null // Ej startad
};
function canPlayCard(card, tableau) {
const row = tableau[card.suit];
// Om färgen ej startad: bara 7:an
if (!row) return card.rank === 7;
// Annars: ett steg upp eller ner
return card.rank === row.low - 1
|| card.rank === row.high + 1;
}
function getCardPoints(card) {
if (card.rank === 1) return 25; // Ess
if (card.rank >= 10) return 10; // 10, Kn, D, K
return 5; // 2-9
}
// Asken-bonus: +50 poäng
En runda slutar när en spelare blir av med alla sina kort. Då:
Robotspelare implementeras helt på serversidan. När det är en robots tur
körs executeBotTurn() efter en kort fördröjning för naturligt flöde.
Spelar ett slumpmässigt giltigt kort. Ingen strategi.
// Välj slumpmässigt bland spelbara kort
const randomCard = playableCards[
Math.floor(Math.random() * playableCards.length)
];
Lägger alla kort den kan utan att tänka långsiktigt.
// Poängsätt sekvenser
const score = sequence.length * 100 + totalPoints;
Avancerad strategi med flera faktorer:
// Smart robot - utvärdera varje möjligt drag
for (const seq of allSequences) {
let score = 0;
// Poäng för att bli av med kort
score += getPoints(seq) * 2;
// Bonus för många kort
score += seq.length * 50;
// Simulera framtida drag
const newTableau = simulatePlay(seq);
const futurePlayable = countPlayable(hand, newTableau);
score += futurePlayable * 30;
// Bonus för ess/kungar
if (hasEdgeCards(seq)) score += 40;
// Välj bästa draget
if (score > bestScore) bestSequence = seq;
}
Applikationen är deployad på Render med en extern Redis-instans för datalagring.
# render.yaml
services:
- type: web
name: asken-online
runtime: node
buildCommand: npm install
startCommand: node server/index.js
envVars:
- key: REDIS_URL
sync: false
- key: NODE_ENV
value: production
| Variabel | Beskrivning |
|---|---|
REDIS_URL |
Anslutningssträng till Redis-server |
PORT |
Port för webbservern (default: 3000) |
NODE_ENV |
production / development |
Render är konfigurerat för automatisk deploy vid push till
main-branchen på GitHub.
Tack till Lana Pistol och Iréne Pistol för idéer och testning.