Om projektet

Teknisk dokumentation för Asken Online

Version 1.1 • December 2025

1. Projektöversikt

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.

Bakgrund

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.

Huvudfunktioner

Multiplayer
3-7 spelare (människor/robotar)
Matchmaking
Hitta andra spelare automatiskt
Robotspelare
3 svårighetsgrader
Chatt
Prata med medspelare
Persistence
Redis-lagring 48h
Responsiv
Mobil & desktop
PWA
Installera som app
Spellägen
Snabb & Standard
Hjälpfunktioner
Visuel guidning

2. Teknisk stack

Node.js
Runtime
Express
Webbserver
Socket.io
Websockets
Redis
Datalagring
CSS3
Styling
Vanilla JS
Klientlogik

Varför dessa tekniker?

3. Arkitektur

Applikationen följer en klassisk klient-server-arkitektur med websocket-kommunikation:

Webbläsare
HTML/CSS/JS
Node.js Server
Express + Socket.io
Redis
Speldata

Kommunikationsflöde

  1. Klienten ansluter via Socket.io och får ett unikt socket-ID
  2. Vid rumskapande genereras en 4-teckens kod och rummet sparas i Redis
  3. Alla spelare i rummet får uppdateringar via gameState-events
  4. Servern validerar alla drag och uppdaterar Redis
  5. Redis TTL (24 timmar) rensar automatiskt inaktiva rum
ℹ️ Stateless server

All speldata lagras i Redis, inte i serverns minne. Detta möjliggör server-restart utan att förlora pågående spel.

4. Filstruktur

asken-online/
  ├── server/
  │   └── index.js         # Huvudserver
  ├── public/
  │   ├── index.html       # Huvudsida + spel
  │   ├── manual.html      # Spelregler
  │   ├── about.html       # Teknisk dok
  │   └── gfx/
  │       ├── asken_logo.webp
  │       ├── bg_wood.webp
  │       ├── bg_dark_steel.webp
  │       └── bg_tyg.webp
  ├── package.json
  └── render.yaml         # Deploy-config

Filer i detalj

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

5. Klientsidan

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.

Skärmar/vyer

State-hantering

let gameState = {
  code: "A1B2",           // Rumskod
  players: [...],         // Spelararray
  tableau: {...},         // Spelplan per färg
  currentPlayerIndex: 0,  // Vems tur
  roundNumber: 1,         // Aktuell runda
  // ... mer state
};

CSS-arkitektur

6. Serversidan

Express-setup

const 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);

Redis-integration

// 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;
}

Huvudsakliga Socket-events

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

7. Realtidskommunikation

Socket.io hanterar all realtidskommunikation mellan klient och server. Varje spelare har en unik socket-anslutning som identifierar dem.

Anslutningshantering

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
  });
});

State-synkronisering

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);
  }
}

Keepalive

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.

8. Matchmaking

Matchmaking-systemet låter spelare hitta varandra utan att behöva dela rumskoder. En global kö hanteras i minnet på servern.

Köhantering

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
    }))
  };
}

Spelstart

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.

9. Chatt

Spelarna kan kommunicera via en inbyggd chatt under spelets gång. Meddelanden skickas via Socket.io och visas för alla i rummet.

Implementation

// 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();
}

Funktioner

10. Spellogik

Kortrepresentation

const card = {
  id: "hearts-7",      // Unikt ID
  suit: "hearts",      // Färg
  rank: 7              // Värde 1-13
};

Tableau (spelplan)

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
};

Validering av drag

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;
}

Poängberäkning

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

Rundslut

En runda slutar när en spelare blir av med alla sina kort. Då:

  1. Beräkna poäng för kvarvarande kort på alla händer
  2. Lägg till +50 för den som har asken
  3. Uppdatera totalpoäng
  4. Kolla om någon nått 500 (standardspel)
  5. Visa resultatmodal

11. Robot-AI

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.

Svårighetsgrader

🤖 Korkad

Spelar ett slumpmässigt giltigt kort. Ingen strategi.

// Välj slumpmässigt bland spelbara kort
const randomCard = playableCards[
  Math.floor(Math.random() * playableCards.length)
];

🤖 Ivrig

Lägger alla kort den kan utan att tänka långsiktigt.

// Poängsätt sekvenser
const score = sequence.length * 100 + totalPoints;

🤖 Smart

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;
}

12. Deployment

Applikationen är deployad på Render med en extern Redis-instans för datalagring.

Render-konfiguration

# 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

Miljövariabler

Variabel Beskrivning
REDIS_URL Anslutningssträng till Redis-server
PORT Port för webbservern (default: 3000)
NODE_ENV production / development

Auto-deploy

Render är konfigurerat för automatisk deploy vid push till main-branchen på GitHub.

🔗 Länkar

Tack till Lana Pistol och Iréne Pistol för idéer och testning.

Skapat av Patrik Pistol